Skip to content
Merged
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
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ plugins {
alias(libs.plugins.maven.publish)
alias(libs.plugins.versions)
alias(libs.plugins.version.catalog.update)
kotlin("plugin.jpa") version "2.3.21"
}

buildscript {
Expand Down Expand Up @@ -204,6 +205,7 @@ dependencies {
.forEach {
api(project(it.name))
}
implementation(kotlin("stdlib"))
}

sourceSets {
Expand Down
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
[versions]
champeau-jmh = "0.7.3"
hamkrest = "1.8.0.1"
hibernate = "7.4.4.Final"
hsqldb = "2.7.2"
jackson = "3.1.3"
jmh = "1.37"
jmhreport = "0.9.6"
jpa = "3.2.0"
junit = "5.11.4"
junit-platform-launcher = "1.9.3"
kotest = "6.1.11"
Expand All @@ -22,9 +25,12 @@ versions = "0.54.0"

[libraries]
hamkrest = { module = "com.natpryce:hamkrest", version.ref = "hamkrest" }
hibernate = { module = "org.hibernate.orm:hibernate-core", version.ref = "hibernate" }
hsqldb = { module = "org.hsqldb:hsqldb", version.ref = "hsqldb" }
jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" }
jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" }
jmh-generator-bytecode = { module = "org.openjdk.jmh:jmh-generator-bytecode", version.ref = "jmh" }
jpa = { module = "jakarta.persistence:jakarta.persistence-api", version.ref = "jpa" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
Expand Down
8 changes: 7 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,11 @@ include("ropes4k")
include("state4k")
include("time4k")
include("tuples4k")
include("tx4k")

"tx4k".apply {
includeModule("core")
includeSubModule("jdbc")
includeSubModule("jpa")
}

include("values4k")
File renamed without changes.
6 changes: 6 additions & 0 deletions tx4k/core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
description = "ForkHandles Transactor library"

dependencies {
testImplementation(kotlin("test-junit5"))
testImplementation(libs.bundles.junit)
}
6 changes: 4 additions & 2 deletions tx4k/build.gradle.kts → tx4k/jdbc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
description = "ForkHandles Transactor library implementation for JDBC"

description = "ForkHandles Transactor library"

dependencies {
api(project(":tx4k"))
testImplementation(kotlin("test-junit5"))
testImplementation(project(path = ":tx4k", configuration = "testArtifacts"))
testImplementation(libs.bundles.junit)
testImplementation(libs.bundles.testcontainers)
testImplementation(libs.bundles.testcontainers.postgres)
testImplementation(libs.bundles.testcontainers.mariadb)
testImplementation("org.hsqldb:hsqldb:2.7.2")
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ class JdbcTransactor<out API>(
}


fun jdbcStandardRetryability(e: Exception): Boolean =
fun jdbcStandardRetryability(e: Throwable): Boolean =
e is SQLException && (e.sqlState == "40001" || e.sqlState == "40P01")
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
@file:OptIn(ExperimentalUuidApi::class)

package dev.forkhandles.tx.jdbc

import dev.forkhandles.tx.Transactional
import org.hsqldb.jdbc.JDBCDataSource
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInfo
import org.testcontainers.containers.JdbcDatabaseContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import kotlin.uuid.ExperimentalUuidApi

class HsqldbInMemoryTransactorTest : JdbcTransactorContract() {
override lateinit var transactor: Transactional<JdbcCounter>

@BeforeEach
fun createCounter(testInfo: TestInfo) {
val testName = testInfo.testMethod.map { it.name }.orElseThrow()
transactor = JdbcTransactor(
createConnection = { dataSource.connection },
createWrapper = { JdbcCounter(it, testName) }
)

transactor.perform { it.init() }
}

companion object {
val dataSource = JDBCDataSource().apply {
database = "jdbc:hsqldb:mem:${HsqldbInMemoryTransactorTest::class.simpleName}"
}

@BeforeAll
@JvmStatic
fun createSchema() {
dataSource.connection.use(::createSchema)
}

@AfterAll
@JvmStatic
fun closeDataSource() {
dataSource.connection.use { c ->
c.createStatement().use { s ->
s.execute("SHUTDOWN")
}
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ fun createSchema(c: Connection): Boolean = c.createStatement().use { s ->
"""
create table COUNTER (
id VARCHAR(64) PRIMARY KEY,
count NUMERIC(8) NOT NULL DEFAULT 0
count NUMERIC(8) DEFAULT 0 NOT NULL
)
"""
)
Expand Down
21 changes: 21 additions & 0 deletions tx4k/jpa/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
description = "ForkHandles Transactor library implementation for JPA"

plugins {
kotlin("plugin.jpa")
}

dependencies {
api(project(":tx4k"))
implementation(project(":tx4k-jdbc"))
compileOnly(libs.jpa)
testImplementation(kotlin("test-junit5"))
testImplementation(libs.hibernate)
testImplementation(project(path = ":tx4k", configuration = "testArtifacts"))
testImplementation(libs.hsqldb)
testImplementation(libs.bundles.junit)
implementation(kotlin("stdlib"))
}

repositories {
mavenCentral()
}
61 changes: 61 additions & 0 deletions tx4k/jpa/src/main/kotlin/dev/forkhandles/tx/jpa/JpaTransactor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package dev.forkhandles.tx.jpa

import dev.forkhandles.tx.RetryPolicy
import dev.forkhandles.tx.Transactor
import dev.forkhandles.tx.increasingBackoff
import dev.forkhandles.tx.jdbc.jdbcStandardRetryability
import dev.forkhandles.tx.withAdditiveJitter
import dev.forkhandles.tx.maxAttempts
import jakarta.persistence.EntityManager
import jakarta.persistence.EntityManagerFactory
import jakarta.persistence.RollbackException
import java.time.Duration


class JpaTransactor<out API>(
private val emf: EntityManagerFactory,
private val retryPolicy: RetryPolicy =
increasingBackoff(Duration.ofMillis(50))
.withAdditiveJitter()
.maxAttempts(5),
private val retryableFailurePolicy: (RollbackException) -> Boolean =
::jpaDefaultRetryability,
private val createWrapper: (EntityManager) -> API

) : Transactor<EntityManager, API>() {
override fun createResource(): EntityManager =
emf.createEntityManager()

override fun configureResource(resource: EntityManager) {
}

override fun destroyResource(resource: EntityManager) =
resource.close()

override fun createApi(resource: EntityManager): API =
createWrapper(resource)

override fun startTransaction(resource: EntityManager) {
resource.transaction.begin()
}

override fun rollbackTransaction(resource: EntityManager) {
resource.transaction.rollback()
}

override fun commitTransaction(resource: EntityManager) {
resource.transaction.commit()
}

override fun canRetry(e: Exception): Boolean =
e is RollbackException && retryableFailurePolicy(e)

override fun retryBackoff(attempt: Int): Duration? =
retryPolicy(attempt)
}

fun jpaDefaultRetryability(e: RollbackException): Boolean =
e.causes().any(::jdbcStandardRetryability)

private fun Throwable.causes(): Sequence<Throwable> =
generateSequence(this) { it.cause }
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package dev.forkhandles.tx.jpa

import dev.forkhandles.tx.Transactional
import jakarta.persistence.EntityManagerFactory
import jakarta.persistence.PersistenceConfiguration
import jakarta.persistence.PersistenceConfiguration.JDBC_URL
import jakarta.persistence.PersistenceConfiguration.SCHEMAGEN_DATABASE_ACTION
import org.hibernate.cfg.JdbcSettings.AUTOCOMMIT
import org.hibernate.cfg.JdbcSettings.CONNECTION_HANDLING
import org.hibernate.cfg.JdbcSettings.DIALECT
import org.hibernate.cfg.JdbcSettings.ISOLATION
import org.hibernate.dialect.HSQLDialect
import org.hibernate.resource.jdbc.spi.PhysicalConnectionHandlingMode.IMMEDIATE_ACQUISITION_AND_HOLD
import org.hibernate.tool.schema.Action.SPEC_ACTION_DROP_AND_CREATE
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.TestInfo
import java.sql.Connection.TRANSACTION_SERIALIZABLE


class HibernateHsqldbJpaTransactorTest : JpaTransactorContract() {
override lateinit var transactor: Transactional<JpaCounter>

@BeforeEach
fun createCounter(testInfo: TestInfo) {
transactor = JpaTransactor(emf) { em ->
JpaCounter(em, testInfo.testMethod.map { it.name }.orElseThrow())
}

transactor.perform { it.init() }
}

companion object {
val persistenceUnitName = HibernateHsqldbJpaTransactorTest::class.simpleName

val persistenceConfiguration: PersistenceConfiguration =
PersistenceConfiguration(persistenceUnitName)
.managedClass(NamedCount::class.java)
.property(JDBC_URL, "jdbc:hsqldb:mem:${persistenceUnitName}")
.property(SCHEMAGEN_DATABASE_ACTION, SPEC_ACTION_DROP_AND_CREATE)
.property(DIALECT, HSQLDialect())
.property(CONNECTION_HANDLING, IMMEDIATE_ACQUISITION_AND_HOLD)
.property(AUTOCOMMIT, false)
.property(ISOLATION, TRANSACTION_SERIALIZABLE)

lateinit var emf: EntityManagerFactory

@BeforeAll
@JvmStatic
fun createSchema() {
emf = persistenceConfiguration.createEntityManagerFactory()
}

@AfterAll
@JvmStatic
fun cleanUp() {
if (::emf.isInitialized) emf.close()
}
}
}

43 changes: 43 additions & 0 deletions tx4k/jpa/src/test/kotlin/dev/forkhandles/tx/jpa/JpaCounter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dev.forkhandles.tx.jpa

import dev.forkhandles.tx.Counter
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EntityManager
import jakarta.persistence.Id

@Entity

class NamedCount(
@Id
var id: String? = null,

@Column
var value: Int = 0
)

class JpaCounter(
val entityManager: EntityManager,
val name: String
) : Counter {
fun init() {
entityManager.persist(NamedCount(name))
}

override fun incrementBy(n: Int) {
val count = loadCount()
count.value += n
}

override fun count(): Int {
return loadCount().value
}

private fun loadCount(): NamedCount =
entityManager.find(NamedCount::class.java, name)
?: error("no count found for name: $name")

fun causeUnrecoverableFailure() {
entityManager.persist(NamedCount(null, 0))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.forkhandles.tx.jpa

import dev.forkhandles.tx.Transactional
import dev.forkhandles.tx.TransactorContract
import org.junit.jupiter.api.assertThrows
import java.sql.SQLException
import kotlin.test.Test

abstract class JpaTransactorContract : TransactorContract() {
abstract override val transactor: Transactional<JpaCounter>

@Test
fun `passes through unrecoverable database errors without retry`() {
assertThrows<Exception> {
transactor.perform { counter ->
counter.causeUnrecoverableFailure()
}
}
}
}