Add multi-datasource module: two databases with per-database DataSource, Flyway, EntityManagerFactory and transaction manager on Boot 4.1
Working setup plus one small application per way of getting it wrong (no @Primary, @Primary only, url vs jdbc-url, wrong repository package, Flyway auto-configuration), plain vs named @Transactional, and ChainedTransactionManager with a failing commit. Transcripts are written by the tests. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Scans only the packages that belong to the two databases and the service that uses both.
|
||||
* The {@code traps} package next to it is deliberately NOT scanned: each trap is its own application.
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {
|
||||
"com.ankurm.multids.customers",
|
||||
"com.ankurm.multids.orders",
|
||||
"com.ankurm.multids.service"})
|
||||
public class MultiDsApp {
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ankurm.multids.customers;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
public class Customer {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String name;
|
||||
private String email;
|
||||
|
||||
// No @Column: the column name comes from the naming strategy (createdAt -> created_at).
|
||||
private Instant createdAt;
|
||||
|
||||
protected Customer() {
|
||||
}
|
||||
|
||||
public Customer(String name, String email) {
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.multids.customers;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface CustomerRepository extends JpaRepository<Customer, Long> {
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.multids.customers;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
|
||||
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Map;
|
||||
|
||||
/** Everything that belongs to the customers database: DataSource, Flyway, EntityManagerFactory, transaction manager, repositories. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableJpaRepositories(
|
||||
basePackageClasses = CustomerRepository.class,
|
||||
entityManagerFactoryRef = "customersEntityManagerFactory",
|
||||
transactionManagerRef = "customersTransactionManager")
|
||||
public class CustomersDbConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
@ConfigurationProperties("app.datasource.customers")
|
||||
DataSourceProperties customersDataSourceProperties() {
|
||||
return new DataSourceProperties();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
@ConfigurationProperties("app.datasource.customers.configuration")
|
||||
HikariDataSource customersDataSource(@Qualifier("customersDataSourceProperties") DataSourceProperties properties) {
|
||||
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
|
||||
}
|
||||
|
||||
@Bean(initMethod = "migrate")
|
||||
Flyway customersFlyway(@Qualifier("customersDataSource") DataSource dataSource) {
|
||||
return Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration/customers")
|
||||
.load();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
@DependsOn("customersFlyway")
|
||||
LocalContainerEntityManagerFactoryBean customersEntityManagerFactory(
|
||||
EntityManagerFactoryBuilder builder, @Qualifier("customersDataSource") DataSource dataSource) {
|
||||
return builder.dataSource(dataSource)
|
||||
.packages(Customer.class)
|
||||
.persistenceUnit("customers")
|
||||
.properties(Map.of("hibernate.hbm2ddl.auto", "validate"))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
PlatformTransactionManager customersTransactionManager(
|
||||
@Qualifier("customersEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
|
||||
return new JpaTransactionManager(entityManagerFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.multids.orders;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ankurm.multids.orders;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
|
||||
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Map;
|
||||
|
||||
/** Everything that belongs to the orders database: DataSource, Flyway, EntityManagerFactory, transaction manager, repositories. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableJpaRepositories(
|
||||
basePackageClasses = OrderRepository.class,
|
||||
entityManagerFactoryRef = "ordersEntityManagerFactory",
|
||||
transactionManagerRef = "ordersTransactionManager")
|
||||
public class OrdersDbConfig {
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("app.datasource.orders")
|
||||
DataSourceProperties ordersDataSourceProperties() {
|
||||
return new DataSourceProperties();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("app.datasource.orders.configuration")
|
||||
HikariDataSource ordersDataSource(@Qualifier("ordersDataSourceProperties") DataSourceProperties properties) {
|
||||
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
|
||||
}
|
||||
|
||||
@Bean(initMethod = "migrate")
|
||||
Flyway ordersFlyway(@Qualifier("ordersDataSource") DataSource dataSource) {
|
||||
return Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration/orders")
|
||||
.load();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@DependsOn("ordersFlyway")
|
||||
LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
|
||||
EntityManagerFactoryBuilder builder, @Qualifier("ordersDataSource") DataSource dataSource) {
|
||||
return builder.dataSource(dataSource)
|
||||
.packages(PurchaseOrder.class)
|
||||
.persistenceUnit("orders")
|
||||
.properties(Map.of("hibernate.hbm2ddl.auto", "validate"))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
PlatformTransactionManager ordersTransactionManager(
|
||||
@Qualifier("ordersEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
|
||||
return new JpaTransactionManager(entityManagerFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ankurm.multids.orders;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "purchase_order")
|
||||
public class PurchaseOrder {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
// A plain number, not a @ManyToOne: the customer lives in the other database.
|
||||
private Long customerId;
|
||||
private BigDecimal amount;
|
||||
private Instant placedAt;
|
||||
|
||||
protected PurchaseOrder() {
|
||||
}
|
||||
|
||||
public PurchaseOrder(Long customerId, BigDecimal amount) {
|
||||
this.customerId = customerId;
|
||||
this.amount = amount;
|
||||
this.placedAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getCustomerId() {
|
||||
return customerId;
|
||||
}
|
||||
|
||||
public BigDecimal getAmount() {
|
||||
return amount;
|
||||
}
|
||||
|
||||
public Instant getPlacedAt() {
|
||||
return placedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.ankurm.multids.service;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import com.ankurm.multids.orders.OrderRepository;
|
||||
import com.ankurm.multids.orders.PurchaseOrder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** Writes a customer (customers database) and an order (orders database), then optionally fails. */
|
||||
@Service
|
||||
public class PlacementService {
|
||||
|
||||
private final CustomerRepository customers;
|
||||
private final OrderRepository orders;
|
||||
|
||||
public PlacementService(CustomerRepository customers, OrderRepository orders) {
|
||||
this.customers = customers;
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
/** Plain {@code @Transactional}: Spring picks the one transaction manager marked {@code @Primary}. */
|
||||
@Transactional
|
||||
public void placeWithDefaultTransaction(String name, BigDecimal amount, boolean failAfterWriting) {
|
||||
writeBoth(name, amount, failAfterWriting);
|
||||
}
|
||||
|
||||
/** Names the orders transaction manager, so the ORDERS write is the one that rolls back. */
|
||||
@Transactional("ordersTransactionManager")
|
||||
public void placeWithOrdersTransaction(String name, BigDecimal amount, boolean failAfterWriting) {
|
||||
writeBoth(name, amount, failAfterWriting);
|
||||
}
|
||||
|
||||
private void writeBoth(String name, BigDecimal amount, boolean failAfterWriting) {
|
||||
Customer customer = customers.save(new Customer(name, name.toLowerCase() + "@example.com"));
|
||||
orders.save(new PurchaseOrder(customer.getId(), amount));
|
||||
if (failAfterWriting) {
|
||||
throw new IllegalStateException("boom after both writes");
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.multids.traps.builderdefaults;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/** A hand-built EntityManagerFactory that passes NO Hibernate properties of its own: what does it still get from Spring Boot? */
|
||||
@SpringBootApplication
|
||||
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class)
|
||||
public class BuilderDefaultsApp {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
DataSource dataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:naming").username("sa").password("").build();
|
||||
}
|
||||
|
||||
@Bean(initMethod = "migrate")
|
||||
Flyway flyway(DataSource dataSource) {
|
||||
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration/customers").load();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@DependsOn("flyway")
|
||||
LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, DataSource dataSource) {
|
||||
return builder.dataSource(dataSource).packages(Customer.class).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.multids.traps.flywayauto;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import com.ankurm.multids.orders.OrderRepository;
|
||||
import com.ankurm.multids.orders.PurchaseOrder;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.boot.jpa.EntityManagerFactoryBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Two databases, two EntityManagerFactories, and NO Flyway beans of our own: Spring Boot's Flyway
|
||||
* auto-configuration is left to do what it does. Set {@code trap.flyway=orders-only} to add a Flyway
|
||||
* bean for the orders database alone.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class FlywayAutoApp {
|
||||
|
||||
// @EnableJpaRepositories is not repeatable, so each scan needs a configuration class of its own.
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class,
|
||||
entityManagerFactoryRef = "customersEntityManagerFactory", transactionManagerRef = "customersTransactionManager")
|
||||
static class CustomersRepositories {
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableJpaRepositories(basePackageClasses = OrderRepository.class,
|
||||
entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
|
||||
static class OrdersRepositories {
|
||||
}
|
||||
|
||||
/** "validate" by default; trap.ddl=none lets the application start so the databases can be inspected. */
|
||||
private static Map<String, String> ddl(Environment env) {
|
||||
return Map.of("hibernate.hbm2ddl.auto", env.getProperty("trap.ddl", "validate"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
DataSource customersDataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:fa-customers").username("sa").password("").build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
DataSource ordersDataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:fa-orders").username("sa").password("").build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
LocalContainerEntityManagerFactoryBean customersEntityManagerFactory(
|
||||
EntityManagerFactoryBuilder builder, @Qualifier("customersDataSource") DataSource dataSource, Environment env) {
|
||||
return builder.dataSource(dataSource).packages(Customer.class).persistenceUnit("customers")
|
||||
.properties(ddl(env)).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
LocalContainerEntityManagerFactoryBean ordersEntityManagerFactory(
|
||||
EntityManagerFactoryBuilder builder, @Qualifier("ordersDataSource") DataSource dataSource, Environment env) {
|
||||
return builder.dataSource(dataSource).packages(PurchaseOrder.class).persistenceUnit("orders")
|
||||
.properties(ddl(env)).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
PlatformTransactionManager customersTransactionManager(
|
||||
@Qualifier("customersEntityManagerFactory") EntityManagerFactory emf) {
|
||||
return new JpaTransactionManager(emf);
|
||||
}
|
||||
|
||||
@Bean
|
||||
PlatformTransactionManager ordersTransactionManager(
|
||||
@Qualifier("ordersEntityManagerFactory") EntityManagerFactory emf) {
|
||||
return new JpaTransactionManager(emf);
|
||||
}
|
||||
|
||||
/** Only when trap.flyway=orders-only: one hand-made Flyway bean, for the orders database. */
|
||||
@Bean(initMethod = "migrate")
|
||||
@ConditionalOnProperty(name = "trap.flyway", havingValue = "orders-only")
|
||||
Flyway ordersFlyway(@Qualifier("ordersDataSource") DataSource dataSource) {
|
||||
return Flyway.configure().dataSource(dataSource).locations("classpath:db/migration/orders").load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.multids.traps.jdbcurl;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/** The copy-paste pattern: bind app.datasource.customers.* straight onto the pool. */
|
||||
@SpringBootApplication
|
||||
public class JdbcUrlApp {
|
||||
|
||||
@Bean
|
||||
@ConfigurationProperties("app.datasource.customers")
|
||||
DataSource customersDataSource() {
|
||||
return DataSourceBuilder.create().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.multids.traps.nodefault;
|
||||
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/** Two DataSource beans, none marked @Primary, and Spring Boot's own JPA setup expected to cope. */
|
||||
@SpringBootApplication
|
||||
@EnableJpaRepositories(basePackageClasses = CustomerRepository.class)
|
||||
public class NoDefaultApp {
|
||||
|
||||
@Bean
|
||||
DataSource customersDataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:nd-customers").username("sa").password("").build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
DataSource ordersDataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:nd-orders").username("sa").password("").build();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.ankurm.multids.traps.primaryonly;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.orders.PurchaseOrder;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
/**
|
||||
* The "it works" trap: one DataSource is @Primary, so Spring Boot builds ONE EntityManagerFactory on it,
|
||||
* and both entities and both repositories end up there. The second DataSource is never used.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EntityScan(basePackageClasses = {Customer.class, PurchaseOrder.class})
|
||||
@EnableJpaRepositories(basePackages = {"com.ankurm.multids.customers", "com.ankurm.multids.orders"})
|
||||
public class PrimaryOnlyApp {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
DataSource customersDataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:po-customers").username("sa").password("").build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
DataSource ordersDataSource() {
|
||||
return DataSourceBuilder.create().url("jdbc:h2:mem:po-orders").username("sa").password("").build();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.multids.traps.wrongpackage;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface CustomerInOrdersRepository extends JpaRepository<Customer, Long> {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.multids.traps.wrongpackage;
|
||||
|
||||
import com.ankurm.multids.orders.OrdersDbConfig;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/** The orders configuration, plus a second repository scan that points a CUSTOMER repository at the ORDERS factory. */
|
||||
@SpringBootApplication
|
||||
@Import(OrdersDbConfig.class)
|
||||
@EnableJpaRepositories(basePackageClasses = CustomerInOrdersRepository.class,
|
||||
entityManagerFactoryRef = "ordersEntityManagerFactory", transactionManagerRef = "ordersTransactionManager")
|
||||
public class WrongPackageApp {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
spring.application.name=multi-datasource
|
||||
spring.main.banner-mode=off
|
||||
|
||||
# One block per database. The prefix is ours ("app."), not Spring Boot's ("spring.datasource.").
|
||||
app.datasource.customers.url=jdbc:h2:mem:customers
|
||||
app.datasource.customers.username=sa
|
||||
app.datasource.customers.password=
|
||||
|
||||
app.datasource.orders.url=jdbc:h2:mem:orders
|
||||
app.datasource.orders.username=sa
|
||||
app.datasource.orders.password=
|
||||
|
||||
# Pool settings are per database too. They bind onto the HikariDataSource we build, under ".configuration".
|
||||
app.datasource.customers.configuration.pool-name=customers-pool
|
||||
app.datasource.customers.configuration.maximum-pool-size=4
|
||||
app.datasource.orders.configuration.pool-name=orders-pool
|
||||
app.datasource.orders.configuration.maximum-pool-size=8
|
||||
|
||||
logging.level.org.flywaydb=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
@@ -0,0 +1,6 @@
|
||||
create table customer (
|
||||
id bigint generated by default as identity primary key,
|
||||
name varchar(100) not null,
|
||||
email varchar(200) not null,
|
||||
created_at timestamp not null
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- customer_id points at a row in a DIFFERENT database, so there is no foreign key here and there cannot be one.
|
||||
create table purchase_order (
|
||||
id bigint generated by default as identity primary key,
|
||||
customer_id bigint not null,
|
||||
amount decimal(10, 2) not null,
|
||||
placed_at timestamp not null
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
create index ix_purchase_order_customer on purchase_order (customer_id);
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** Starts a Spring Boot context without a web server and returns either the context or the failure. */
|
||||
public final class BootRun {
|
||||
|
||||
private BootRun() {
|
||||
}
|
||||
|
||||
public static Result run(String[] extraProperties, Class<?>... sources) {
|
||||
var props = new java.util.ArrayList<String>();
|
||||
props.add("spring.main.banner-mode=off");
|
||||
props.add("logging.level.root=OFF");
|
||||
props.addAll(java.util.List.of(extraProperties));
|
||||
try {
|
||||
ConfigurableApplicationContext ctx = new SpringApplicationBuilder(sources)
|
||||
.web(WebApplicationType.NONE)
|
||||
.properties(props.toArray(String[]::new))
|
||||
.run();
|
||||
return new Result(ctx, null);
|
||||
} catch (RuntimeException e) {
|
||||
return new Result(null, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Names the exception classes from the outside in: what a stack trace's "Caused by:" lines say, minus the noise. */
|
||||
public static String chain(Throwable t) {
|
||||
var names = new java.util.ArrayList<String>();
|
||||
for (Throwable c = t; c != null && !names.contains(c.getClass().getSimpleName() + c.hashCode()); c = c.getCause()) {
|
||||
names.add(c.getClass().getSimpleName());
|
||||
if (c.getCause() == c) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return String.join(" -> ", names);
|
||||
}
|
||||
|
||||
public static Throwable root(Throwable t) {
|
||||
while (t.getCause() != null && t.getCause() != t) {
|
||||
t = t.getCause();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
public record Result(ConfigurableApplicationContext context, RuntimeException failure) {
|
||||
|
||||
public boolean started() {
|
||||
return failure == null;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (context != null) {
|
||||
context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import com.ankurm.multids.chain.ChainedApp;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.transaction.HeuristicCompletionException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/** Transcript 09: ChainedTransactionManager, on the happy path and when a COMMIT fails. */
|
||||
class ChainedTests {
|
||||
|
||||
@Test
|
||||
void chainedTransactionManager() {
|
||||
try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ChainedApp.class)
|
||||
.web(WebApplicationType.NONE).properties("logging.level.root=OFF").run();
|
||||
var t = new Transcript("09-chained-transaction-manager.txt",
|
||||
"ChainedTransactionManager over the customers and orders managers")) {
|
||||
var service = ctx.getBean(ChainedApp.ChainedPlacement.class);
|
||||
var customersDb = Jdbc.on(ctx, "customersDataSource");
|
||||
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
|
||||
|
||||
t.section("1. business exception after both writes: both roll back");
|
||||
assertThatThrownBy(() -> service.placeThenFail("Asha")).isInstanceOf(IllegalStateException.class);
|
||||
t.line("rows: customers.customer=%d orders.purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
|
||||
assertThat(Jdbc.count(customersDb, "customer")).isZero();
|
||||
assertThat(Jdbc.count(ordersDb, "purchase_order")).isZero();
|
||||
|
||||
t.section("2. the orders database refuses to commit; orders is LAST in the list");
|
||||
Throwable last = catchThrowable(() -> service.placeWhenOrdersCommitsLast("Bela"));
|
||||
t.line("thrown: %s", describe(last));
|
||||
t.line("rows: customers.customer=%d orders.purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
|
||||
int customersAfterLast = Jdbc.count(customersDb, "customer");
|
||||
customersDb.update("delete from customer");
|
||||
ordersDb.update("delete from purchase_order");
|
||||
|
||||
t.section("3. the orders database refuses to commit; orders is FIRST in the list");
|
||||
Throwable first = catchThrowable(() -> service.placeWhenOrdersCommitsFirst("Chitra"));
|
||||
t.line("thrown: %s", describe(first));
|
||||
t.line("rows: customers.customer=%d orders.purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
|
||||
int customersAfterFirst = Jdbc.count(customersDb, "customer");
|
||||
|
||||
// The point of the section: the outcome depends on the ORDER of the list.
|
||||
assertThat(customersAfterLast).isNotEqualTo(customersAfterFirst);
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(Throwable e) {
|
||||
var h = (HeuristicCompletionException) e;
|
||||
return e.getClass().getName() + " (outcome: " + HeuristicCompletionException.getStateString(h.getOutcomeState()) + ")";
|
||||
}
|
||||
|
||||
private static Throwable catchThrowable(Runnable r) {
|
||||
try {
|
||||
r.run();
|
||||
} catch (Throwable e) {
|
||||
return e;
|
||||
}
|
||||
throw new AssertionError("expected the commit to fail");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
|
||||
/** Tiny helpers: ask a named DataSource a question with plain JDBC, bypassing JPA entirely. */
|
||||
public final class Jdbc {
|
||||
|
||||
private Jdbc() {
|
||||
}
|
||||
|
||||
public static JdbcTemplate on(ApplicationContext ctx, String dataSourceBean) {
|
||||
return new JdbcTemplate(ctx.getBean(dataSourceBean, DataSource.class));
|
||||
}
|
||||
|
||||
public static int count(JdbcTemplate jdbc, String table) {
|
||||
return jdbc.queryForObject("select count(*) from " + table, Integer.class);
|
||||
}
|
||||
|
||||
public static List<String> tables(JdbcTemplate jdbc) {
|
||||
return jdbc.queryForList(
|
||||
"select table_name from information_schema.tables where table_schema = 'PUBLIC' order by table_name", String.class);
|
||||
}
|
||||
|
||||
public static List<String> migrations(JdbcTemplate jdbc) {
|
||||
return jdbc.queryForList(
|
||||
"select \"version\" || ' ' || \"description\" from \"flyway_schema_history\" where \"version\" is not null order by \"installed_rank\"", String.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String text = buffer.toString().replace(System.getProperty("user.dir"), "<multi-datasource>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import com.ankurm.multids.orders.OrderRepository;
|
||||
import com.ankurm.multids.orders.PurchaseOrder;
|
||||
import com.ankurm.multids.traps.builderdefaults.BuilderDefaultsApp;
|
||||
import com.ankurm.multids.traps.flywayauto.FlywayAutoApp;
|
||||
import com.ankurm.multids.traps.jdbcurl.JdbcUrlApp;
|
||||
import com.ankurm.multids.traps.nodefault.NoDefaultApp;
|
||||
import com.ankurm.multids.traps.primaryonly.PrimaryOnlyApp;
|
||||
import com.ankurm.multids.traps.wrongpackage.WrongPackageApp;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** Transcripts 02-07: every way of getting the setup wrong that the article describes, run for real. */
|
||||
class TrapsTests {
|
||||
|
||||
private static void failure(Transcript t, BootRun.Result r) {
|
||||
assertThat(r.started()).as("the application should have failed to start").isFalse();
|
||||
t.line("startup failed: %s", BootRun.chain(r.failure()));
|
||||
t.line("root cause: %s: %s", BootRun.root(r.failure()).getClass().getName(), BootRun.root(r.failure()).getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoDataSourcesAndNoPrimary() {
|
||||
try (var t = new Transcript("02-no-primary.txt", "Two DataSource beans, none marked @Primary")) {
|
||||
var r = BootRun.run(new String[0], NoDefaultApp.class);
|
||||
failure(t, r);
|
||||
assertThat(BootRun.root(r.failure()).getMessage()).contains("No bean named 'entityManagerFactory'");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneDataSourcePrimaryAndTheOtherSilentlyUnused() {
|
||||
try (var t = new Transcript("03-primary-only-wrong-database.txt",
|
||||
"One @Primary DataSource and nothing else: it starts, and it writes to the wrong database")) {
|
||||
var r = BootRun.run(new String[]{"spring.flyway.enabled=false"}, PrimaryOnlyApp.class);
|
||||
assertThat(r.started()).isTrue();
|
||||
var ctx = r.context();
|
||||
try {
|
||||
var customer = ctx.getBean(CustomerRepository.class).save(new Customer("Asha", "[email protected]"));
|
||||
ctx.getBean(OrderRepository.class).save(new PurchaseOrder(customer.getId(), new BigDecimal("499.00")));
|
||||
var customersDb = Jdbc.on(ctx, "customersDataSource");
|
||||
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
|
||||
t.line("application started: yes");
|
||||
t.line("EntityManagerFactory beans: %s", List.of(ctx.getBeanNamesForType(EntityManagerFactory.class)));
|
||||
t.line("customers database tables: %s", Jdbc.tables(customersDb));
|
||||
t.line("orders database tables: %s", Jdbc.tables(ordersDb));
|
||||
t.line("rows in the customers database: customer=%d purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(customersDb, "purchase_order"));
|
||||
assertThat(Jdbc.tables(ordersDb)).isEmpty();
|
||||
assertThat(Jdbc.count(customersDb, "purchase_order")).isEqualTo(1);
|
||||
var emf = ctx.getBean(EntityManagerFactory.class);
|
||||
t.line("hibernate.hbm2ddl.auto on that factory: %s", emf.getProperties().get("hibernate.hbm2ddl.auto"));
|
||||
} finally {
|
||||
r.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlDoesNotBindOntoAPool() {
|
||||
try (var t = new Transcript("04-url-vs-jdbc-url.txt",
|
||||
"DataSourceBuilder.create().build() bound to app.datasource.customers.url")) {
|
||||
var r = BootRun.run(new String[]{"spring.flyway.enabled=false"}, JdbcUrlApp.class);
|
||||
failure(t, r);
|
||||
assertThat(BootRun.root(r.failure()).getMessage()).contains("jdbcUrl is required");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void whatTheBuilderAlreadyGivesAHandBuiltFactory() {
|
||||
try (var t = new Transcript("05-builder-defaults.txt",
|
||||
"A hand-built EntityManagerFactory that sets no Hibernate properties of its own")) {
|
||||
var r = BootRun.run(new String[0], BuilderDefaultsApp.class);
|
||||
assertThat(r.started()).isTrue();
|
||||
try {
|
||||
var props = r.context().getBean(EntityManagerFactory.class).getProperties();
|
||||
t.line("hibernate.physical_naming_strategy = %s", props.get("hibernate.physical_naming_strategy"));
|
||||
t.line("hibernate.implicit_naming_strategy = %s", props.get("hibernate.implicit_naming_strategy"));
|
||||
t.line("hibernate.hbm2ddl.auto = %s", props.get("hibernate.hbm2ddl.auto"));
|
||||
var saved = r.context().getBean(CustomerRepository.class).save(new Customer("Asha", "[email protected]"));
|
||||
t.line("saved a Customer whose field is createdAt into a column created_at: id=%d", saved.getId());
|
||||
assertThat(props.get("hibernate.physical_naming_strategy").toString()).contains("SnakeCase");
|
||||
assertThat(props.get("hibernate.hbm2ddl.auto")).isNull();
|
||||
} finally {
|
||||
r.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void whatEachDatabaseHolds(Transcript t, BootRun.Result r) {
|
||||
assertThat(r.started()).isTrue();
|
||||
try {
|
||||
t.line("application started (Hibernate validation switched off so the databases can be inspected)");
|
||||
t.line("customers database tables: %s", Jdbc.tables(Jdbc.on(r.context(), "customersDataSource")));
|
||||
t.line("orders database tables: %s", Jdbc.tables(Jdbc.on(r.context(), "ordersDataSource")));
|
||||
} finally {
|
||||
r.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void flywayAutoConfigurationWithTwoDatabases() {
|
||||
try (var t = new Transcript("06-flyway-auto-configuration.txt",
|
||||
"Spring Boot's Flyway auto-configuration when there are two databases")) {
|
||||
String customersOnly = "spring.flyway.locations=classpath:db/migration/customers";
|
||||
|
||||
t.section("A. nothing configured: Flyway targets the @Primary DataSource and scans classpath:db/migration");
|
||||
var a = BootRun.run(new String[0], FlywayAutoApp.class);
|
||||
failure(t, a);
|
||||
assertThat(BootRun.root(a.failure()).getMessage()).contains("Found more than one migration with version 1");
|
||||
|
||||
t.section("B. spring.flyway.locations=classpath:db/migration/customers");
|
||||
var b = BootRun.run(new String[]{customersOnly}, FlywayAutoApp.class);
|
||||
failure(t, b);
|
||||
assertThat(BootRun.root(b.failure()).getMessage()).contains("missing table [purchase_order]");
|
||||
t.blank();
|
||||
var b2 = BootRun.run(new String[]{customersOnly, "trap.ddl=none"}, FlywayAutoApp.class);
|
||||
whatEachDatabaseHolds(t, b2);
|
||||
|
||||
t.section("C. as B, plus one hand-made Flyway bean for the orders database");
|
||||
var c = BootRun.run(new String[]{customersOnly, "trap.flyway=orders-only"}, FlywayAutoApp.class);
|
||||
failure(t, c);
|
||||
assertThat(BootRun.root(c.failure()).getMessage()).contains("missing table [customer]");
|
||||
t.blank();
|
||||
var c2 = BootRun.run(new String[]{customersOnly, "trap.flyway=orders-only", "trap.ddl=none"}, FlywayAutoApp.class);
|
||||
whatEachDatabaseHolds(t, c2);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRepositoryInThePackageOfTheWrongFactory() {
|
||||
try (var t = new Transcript("07-wrong-package.txt",
|
||||
"A repository for Customer scanned against the orders EntityManagerFactory")) {
|
||||
var r = BootRun.run(new String[0], WrongPackageApp.class);
|
||||
failure(t, r);
|
||||
assertThat(BootRun.root(r.failure()).getMessage()).contains("Not a managed type");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.ankurm.multids;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import com.ankurm.multids.orders.OrderRepository;
|
||||
import com.ankurm.multids.orders.PurchaseOrder;
|
||||
import com.ankurm.multids.service.PlacementService;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import jakarta.persistence.metamodel.EntityType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/** Transcripts 01 (the working setup) and 08 (which transaction manager a plain @Transactional gets). */
|
||||
class TwoDatabasesTests {
|
||||
|
||||
private static ConfigurableApplicationContext start() {
|
||||
return new SpringApplicationBuilder(MultiDsApp.class)
|
||||
.web(WebApplicationType.NONE)
|
||||
.properties("logging.level.root=WARN")
|
||||
.run();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theWorkingSetup() {
|
||||
try (ConfigurableApplicationContext ctx = start(); var t = new Transcript("01-two-databases.txt",
|
||||
"Two databases, each with its own DataSource, Flyway, EntityManagerFactory and transaction manager")) {
|
||||
|
||||
t.section("the two DataSources (one pool each, configured separately)");
|
||||
for (String name : List.of("customersDataSource", "ordersDataSource")) {
|
||||
var pool = ctx.getBean(name, HikariDataSource.class);
|
||||
t.line("%-20s pool=%-15s max=%d url=%s primary=%s", name, pool.getPoolName(),
|
||||
pool.getMaximumPoolSize(), pool.getJdbcUrl(), ctx.getBeanFactory().getBeanDefinition(name).isPrimary());
|
||||
}
|
||||
|
||||
t.section("Flyway ran once per database, each with its own history");
|
||||
var customersDb = Jdbc.on(ctx, "customersDataSource");
|
||||
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
|
||||
t.line("customers flyway_schema_history: %s", Jdbc.migrations(customersDb));
|
||||
t.line("orders flyway_schema_history: %s", Jdbc.migrations(ordersDb));
|
||||
assertThat(Jdbc.migrations(customersDb)).hasSize(1);
|
||||
assertThat(Jdbc.migrations(ordersDb)).hasSize(2);
|
||||
|
||||
t.section("what each EntityManagerFactory manages");
|
||||
t.line("EntityManagerFactory beans: %s", Arrays.stream(ctx.getBeanNamesForType(EntityManagerFactory.class)).sorted().toList());
|
||||
for (String name : List.of("customersEntityManagerFactory", "ordersEntityManagerFactory")) {
|
||||
var emf = ctx.getBean(name, EntityManagerFactory.class);
|
||||
t.line("%-32s entities=%s", name,
|
||||
emf.getMetamodel().getEntities().stream().map(EntityType::getName).toList());
|
||||
}
|
||||
|
||||
t.section("transaction managers");
|
||||
t.line("beans: %s", Arrays.stream(ctx.getBeanNamesForType(PlatformTransactionManager.class)).sorted().toList());
|
||||
for (String name : List.of("customersTransactionManager", "ordersTransactionManager")) {
|
||||
t.line("%-30s primary=%s", name, ctx.getBeanFactory().getBeanDefinition(name).isPrimary());
|
||||
}
|
||||
|
||||
t.section("save one customer and one order through the two repositories");
|
||||
var customer = ctx.getBean(CustomerRepository.class).save(
|
||||
new Customer("Asha", "[email protected]"));
|
||||
ctx.getBean(OrderRepository.class).save(
|
||||
new PurchaseOrder(customer.getId(), new BigDecimal("499.00")));
|
||||
t.line("customers database tables: %s", Jdbc.tables(customersDb));
|
||||
t.line("orders database tables: %s", Jdbc.tables(ordersDb));
|
||||
t.line("rows: customers.customer=%d orders.purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
|
||||
assertThat(Jdbc.tables(customersDb)).doesNotContain("PURCHASE_ORDER");
|
||||
assertThat(Jdbc.tables(ordersDb)).doesNotContain("CUSTOMER");
|
||||
assertThat(Jdbc.count(customersDb, "customer")).isEqualTo(1);
|
||||
assertThat(Jdbc.count(ordersDb, "purchase_order")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainTransactionalGetsThePrimaryManager() {
|
||||
try (ConfigurableApplicationContext ctx = start(); var t = new Transcript("08-which-transaction-manager.txt",
|
||||
"A service method that writes to both databases, then throws")) {
|
||||
var service = ctx.getBean(PlacementService.class);
|
||||
var customersDb = Jdbc.on(ctx, "customersDataSource");
|
||||
var ordersDb = Jdbc.on(ctx, "ordersDataSource");
|
||||
|
||||
t.section("@Transactional (no name): Spring picks the @Primary manager, customersTransactionManager");
|
||||
assertThatThrownBy(() -> service.placeWithDefaultTransaction("Asha", new BigDecimal("10"), true))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
t.line("thrown: java.lang.IllegalStateException: boom after both writes");
|
||||
t.line("rows after the rollback: customers.customer=%d orders.purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
|
||||
assertThat(Jdbc.count(customersDb, "customer")).isZero();
|
||||
assertThat(Jdbc.count(ordersDb, "purchase_order")).isEqualTo(1);
|
||||
|
||||
customersDb.update("delete from customer");
|
||||
ordersDb.update("delete from purchase_order");
|
||||
|
||||
t.section("@Transactional(\"ordersTransactionManager\"): the other database is now the one that rolls back");
|
||||
assertThatThrownBy(() -> service.placeWithOrdersTransaction("Bela", new BigDecimal("10"), true))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
t.line("thrown: java.lang.IllegalStateException: boom after both writes");
|
||||
t.line("rows after the rollback: customers.customer=%d orders.purchase_order=%d",
|
||||
Jdbc.count(customersDb, "customer"), Jdbc.count(ordersDb, "purchase_order"));
|
||||
assertThat(Jdbc.count(customersDb, "customer")).isEqualTo(1);
|
||||
assertThat(Jdbc.count(ordersDb, "purchase_order")).isZero();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ankurm.multids.chain;
|
||||
|
||||
import com.ankurm.multids.customers.Customer;
|
||||
import com.ankurm.multids.customers.CustomerRepository;
|
||||
import com.ankurm.multids.customers.CustomersDbConfig;
|
||||
import com.ankurm.multids.orders.OrderRepository;
|
||||
import com.ankurm.multids.orders.OrdersDbConfig;
|
||||
import com.ankurm.multids.orders.PurchaseOrder;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.transaction.ChainedTransactionManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** The two real configurations, plus three "chained" transaction managers and a service that uses them. */
|
||||
@SpringBootApplication
|
||||
@Import({CustomersDbConfig.class, OrdersDbConfig.class})
|
||||
public class ChainedApp {
|
||||
|
||||
/** customers, then orders: the list ChainedTransactionManager is given. */
|
||||
@Bean
|
||||
@SuppressWarnings("deprecation")
|
||||
PlatformTransactionManager chained(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
|
||||
@Qualifier("ordersTransactionManager") PlatformTransactionManager orders) {
|
||||
return new ChainedTransactionManager(customers, orders);
|
||||
}
|
||||
|
||||
@Bean
|
||||
PlatformTransactionManager failingOrders(@Qualifier("ordersEntityManagerFactory") EntityManagerFactory emf) {
|
||||
return new CommitFailingTransactionManager(emf);
|
||||
}
|
||||
|
||||
/** The orders side is listed FIRST and refuses to commit. */
|
||||
@Bean
|
||||
@SuppressWarnings("deprecation")
|
||||
PlatformTransactionManager chainOrdersFirst(@Qualifier("failingOrders") PlatformTransactionManager failingOrders,
|
||||
@Qualifier("customersTransactionManager") PlatformTransactionManager customers) {
|
||||
return new ChainedTransactionManager(failingOrders, customers);
|
||||
}
|
||||
|
||||
/** The orders side is listed LAST and refuses to commit. */
|
||||
@Bean
|
||||
@SuppressWarnings("deprecation")
|
||||
PlatformTransactionManager chainOrdersLast(@Qualifier("customersTransactionManager") PlatformTransactionManager customers,
|
||||
@Qualifier("failingOrders") PlatformTransactionManager failingOrders) {
|
||||
return new ChainedTransactionManager(customers, failingOrders);
|
||||
}
|
||||
|
||||
@Service
|
||||
public static class ChainedPlacement {
|
||||
|
||||
private final CustomerRepository customers;
|
||||
private final OrderRepository orders;
|
||||
|
||||
ChainedPlacement(CustomerRepository customers, OrderRepository orders) {
|
||||
this.customers = customers;
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
@Transactional("chained")
|
||||
public void placeThenFail(String name) {
|
||||
write(name);
|
||||
throw new IllegalStateException("boom after both writes");
|
||||
}
|
||||
|
||||
@Transactional("chainOrdersFirst")
|
||||
public void placeWhenOrdersCommitsFirst(String name) {
|
||||
write(name);
|
||||
}
|
||||
|
||||
@Transactional("chainOrdersLast")
|
||||
public void placeWhenOrdersCommitsLast(String name) {
|
||||
write(name);
|
||||
}
|
||||
|
||||
private void write(String name) {
|
||||
Customer customer = customers.save(new Customer(name, name.toLowerCase() + "@example.com"));
|
||||
orders.save(new PurchaseOrder(customer.getId(), new BigDecimal("10.00")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.multids.chain;
|
||||
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.transaction.TransactionSystemException;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
|
||||
/** Stands in for "the database refused the COMMIT": a real JpaTransactionManager whose commit always throws. */
|
||||
public class CommitFailingTransactionManager extends JpaTransactionManager {
|
||||
|
||||
public CommitFailingTransactionManager(EntityManagerFactory emf) {
|
||||
super(emf);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doCommit(DefaultTransactionStatus status) {
|
||||
throw new TransactionSystemException("simulated: the orders database refused the commit");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user