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
11 changes: 11 additions & 0 deletions storm-spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@
<artifactId>spring-tx</artifactId>
<scope>provided</scope>
</dependency>
<!-- JPA transaction manager support for the transaction bridge; guarded by a class-presence check. -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Servlet support for the per-request SQL log filter; guarded by @ConditionalOnClass. -->
<dependency>
<groupId>org.springframework</groupId>
Expand Down
2 changes: 2 additions & 0 deletions storm-spring/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
requires static micrometer.tracing;
requires static micrometer.commons;
requires spring.jdbc;
requires static spring.orm;
requires static jakarta.persistence;
requires spring.tx;
requires spring.context;
requires spring.beans;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,9 @@ public SpringTransactionTemplateProvider() {

/**
* Creates a provider that bridges Storm-initiated transactions through the given transaction managers; the
* matching {@code DataSourceTransactionManager} is resolved lazily, when the first data source touches the
* transaction.
* manager owning the touched {@code DataSource} is resolved lazily, when the first data source touches the
* transaction. Both JDBC transaction managers and JPA transaction managers backed by the data source
* qualify.
*
* @param transactionManagers supplies the transaction managers of the owning application context.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,22 @@
* {@code ORMTemplate} created by the starter's auto-configuration; nothing is registered globally. Define your
* own {@link ConnectionProvider} or {@link TransactionTemplateProvider} bean to override.</p>
*
* <p>The ordering hints reference DataSourceTransactionManagerAutoConfiguration by name rather than by class
* literal: the class moved to the modular spring-boot-jdbc jar in Spring Boot 4, and a class literal to
* whichever location is absent would fail annotation introspection. Name-based hints are ignored when the
* class is not on the classpath, so both locations can be listed safely.</p>
* <p>The ordering hints cover both auto-configurations that register a transaction manager: the JDBC one and,
* for applications with JPA on the class path where the JDBC one backs off, the Hibernate JPA one. They
* reference the classes by name rather than by class literal: the classes moved to modular jars in Spring
* Boot 4, and a class literal to whichever location is absent would fail annotation introspection. Name-based
* hints are ignored when the class is not on the classpath, so both locations can be listed safely.</p>
*
* @since 1.13
*/
@AutoConfiguration(
afterName = {
// Spring Boot 3.x location.
// Spring Boot 3.x locations.
"org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration",
// Spring Boot 4.x location.
"org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration",
// Spring Boot 4.x locations.
"org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration",
"org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration",
})
@ConditionalOnBean(PlatformTransactionManager.class)
public class StormTransactionAutoConfiguration {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,16 @@
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.ResourceTransactionManager;
import org.springframework.util.ClassUtils;
import st.orm.Entity;
import st.orm.PersistenceException;
import st.orm.TransactionTimedOutException;
Expand Down Expand Up @@ -409,17 +412,75 @@ public void setRollbackOnly() {
}
}

/**
* Resolves the transaction manager that owns the given data source.
*
* <p>A manager owns the data source when it is a {@link ResourceTransactionManager} working directly on it,
* which covers {@code DataSourceTransactionManager} and {@code JdbcTransactionManager}, or a JPA transaction
* manager whose entity manager factory is backed by it, which is what Spring Boot registers when JPA is on
* the class path. Resolution fails fast when several managers own the same data source: the choice decides
* which manager completes Storm-initiated transactions, so it must be made by configuration rather than by
* list order.</p>
*/
private PlatformTransactionManager resolveTransactionManager(@Nonnull DataSource dataSource) {
return transactionManagers.get().stream()
.filter(DataSourceTransactionManager.class::isInstance)
.map(DataSourceTransactionManager.class::cast)
.filter(manager -> manager.getDataSource() == dataSource)
.map(PlatformTransactionManager.class::cast)
var candidates = transactionManagers.get().stream()
.filter(manager -> managesDataSource(manager, dataSource))
.toList();
if (candidates.size() > 1) {
throw new IllegalStateException(
"Multiple TransactionManagers found for DataSource " + dataSource + ": "
+ candidates.stream()
.map(manager -> manager.getClass().getName())
.collect(Collectors.joining(", "))
+ ". Keep a single transaction manager per DataSource, or define a "
+ "TransactionTemplateProvider bean constructed with the manager that must own "
+ "Storm-initiated transactions.");
}
return candidates.stream()
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"No TransactionManager found for DataSource " + dataSource + "."));
}

private static final boolean JPA_PRESENT = ClassUtils.isPresent(
"org.springframework.orm.jpa.JpaTransactionManager",
SpringTransactionContext.class.getClassLoader());

private static boolean managesDataSource(@Nonnull PlatformTransactionManager manager,
@Nonnull DataSource dataSource) {
if (JPA_PRESENT && JpaSupport.isJpaTransactionManager(manager)) {
return JpaSupport.managesDataSource(manager, dataSource);
}
if (manager instanceof ResourceTransactionManager resourceManager) {
return resourceFactoryOrNull(resourceManager) == dataSource;
}
return false;
}

@Nullable
private static Object resourceFactoryOrNull(@Nonnull ResourceTransactionManager manager) {
try {
return manager.getResourceFactory();
} catch (IllegalStateException e) {
// The manager has no resource factory configured, so it owns no data source.
return null;
}
}

/**
* Touches spring-orm types; only loaded when spring-orm is on the class path.
*/
private static final class JpaSupport {
static boolean isJpaTransactionManager(@Nonnull PlatformTransactionManager manager) {
return manager instanceof JpaTransactionManager;
}

static boolean managesDataSource(@Nonnull PlatformTransactionManager manager,
@Nonnull DataSource dataSource) {
return manager instanceof JpaTransactionManager jpaManager && jpaManager.getDataSource() == dataSource;
}
}

/**
* Starts a Spring TransactionStatus for the given state if not already started.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* Copyright 2024 - 2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package st.orm.spring;

import static java.util.Objects.requireNonNull;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static st.orm.TransactionPropagation.REQUIRES_NEW;
import static st.orm.template.Transactions.transaction;

import jakarta.persistence.EntityManagerFactory;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import javax.sql.DataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import st.orm.repository.EntityRepository;
import st.orm.spring.model.Pet;
import st.orm.spring.model.Visit;
import st.orm.template.ORMTemplate;
import st.orm.template.Transactions;

/**
* Verifies that Storm's programmatic transaction API ({@link Transactions}) is bridged into a
* {@link JpaTransactionManager}, which is the transaction manager a Spring Boot application gets when JPA is
* on the class path. The manager is matched by the {@code DataSource} backing its entity manager factory, so
* Storm-initiated transactions and JPA share one transaction system without a
* {@code DataSourceTransactionManager} being present.
*/
class SpringJpaTransactionBridgeTest {

private DataSource dataSource;
private LocalContainerEntityManagerFactoryBean entityManagerFactoryBean;
private JpaTransactionManager transactionManager;
private ORMTemplate orm;
private EntityRepository<Visit, Integer> visits;
private Pet pet;

@BeforeEach
void setUp() {
dataSource = DataSourceBuilder.create()
.url("jdbc:h2:mem:jpabridgetest;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false")
.username("sa")
.password("")
.driverClassName("org.h2.Driver")
.build();
new ResourceDatabasePopulator(new ClassPathResource("data.sql")).execute(dataSource);
entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
entityManagerFactoryBean.setDataSource(dataSource);
entityManagerFactoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
entityManagerFactoryBean.setPersistenceUnitName("jpa-bridge-test");
// The persistence unit carries no JPA entities; it exists to back the transaction manager.
entityManagerFactoryBean.setPackagesToScan(getClass().getPackageName());
entityManagerFactoryBean.afterPropertiesSet();
EntityManagerFactory entityManagerFactory = requireNonNull(entityManagerFactoryBean.getObject());
transactionManager = new JpaTransactionManager(entityManagerFactory);
orm = SpringOrmTemplate.of(dataSource, () -> List.of(transactionManager));
visits = orm.entity(Visit.class);
pet = orm.entity(Pet.class).getById(1);
}

@AfterEach
void tearDown() {
entityManagerFactoryBean.destroy();
}

private void insertVisit(String description) {
visits.insert(new Visit(null, LocalDate.now(), description, pet, Instant.now()));
}

@Test
void programmaticTransactionCommitsThroughJpaManager() {
long before = visits.count();
transaction(tx -> {
insertVisit("committed");
return null;
});
assertEquals(before + 1, visits.count());
}

@Test
void programmaticRollbackDiscardsWrites() {
long before = visits.count();
transaction(tx -> {
insertVisit("discarded");
assertEquals(before + 1, visits.count());
tx.setRollbackOnly();
return null;
});
assertEquals(before, visits.count());
}

@Test
void requiresNewCommitsIndependentlyOfOuterRollback() {
long before = visits.count();
transaction(outer -> {
insertVisit("outer, discarded");
transaction(REQUIRES_NEW, inner -> {
insertVisit("inner, committed");
return null;
});
outer.setRollbackOnly();
return null;
});
assertEquals(before + 1, visits.count());
}

@Test
void stormBlockJoinsJpaManagedTransaction() {
long before = visits.count();
var springTransaction = new org.springframework.transaction.support.TransactionTemplate(transactionManager);
springTransaction.executeWithoutResult(status -> {
// A Storm programmatic block inside a JPA-managed transaction joins it (REQUIRED).
transaction(tx -> {
insertVisit("joined, discarded");
return null;
});
assertEquals(before + 1, visits.count());
status.setRollbackOnly();
});
// The Spring rollback discarded the write made by the joined Storm block.
assertEquals(before, visits.count());
}

@Test
void jpaManagerForAnotherDataSourceIsNotMatched() {
DataSource otherDataSource = DataSourceBuilder.create()
.url("jdbc:h2:mem:jpabridgeother;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false")
.username("sa")
.password("")
.driverClassName("org.h2.Driver")
.build();
// Resolution inspects the manager's DataSource only, so no entity manager factory is needed.
var otherManager = new JpaTransactionManager();
otherManager.setDataSource(otherDataSource);
ORMTemplate bridged = SpringOrmTemplate.of(dataSource,
() -> List.of(otherManager, new DataSourceTransactionManager(dataSource)));
EntityRepository<Visit, Integer> bridgedVisits = bridged.entity(Visit.class);
long before = bridgedVisits.count();
transaction(tx -> {
bridgedVisits.insert(new Visit(null, LocalDate.now(), "committed", pet, Instant.now()));
return null;
});
assertEquals(before + 1, bridgedVisits.count());
}

@Test
void multipleManagersForTheSameDataSourceFailFast() {
ORMTemplate ambiguous = SpringOrmTemplate.of(dataSource,
() -> List.of(new DataSourceTransactionManager(dataSource), transactionManager));
EntityRepository<Visit, Integer> ambiguousVisits = ambiguous.entity(Visit.class);
long before = ambiguousVisits.count();
Exception exception = assertThrows(Exception.class, () ->
transaction(tx -> {
ambiguousVisits.insert(new Visit(null, LocalDate.now(), "never", pet, Instant.now()));
return null;
}));
String message = messageChain(exception);
assertTrue(message.contains("Multiple TransactionManagers found"), message);
assertTrue(message.contains(DataSourceTransactionManager.class.getName()), message);
assertTrue(message.contains(JpaTransactionManager.class.getName()), message);
assertEquals(before, ambiguousVisits.count());
}

private static String messageChain(Throwable throwable) {
var builder = new StringBuilder();
for (Throwable current = throwable; current != null; current = current.getCause()) {
builder.append(current.getMessage()).append('\n');
}
return builder.toString();
}
}
Loading
Loading