r2dbc: Spring Data R2DBC CRUD, DatabaseClient, reactive transactions and Testcontainers Postgres (post 2)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Table("account")
|
||||
public record Account(@Id Long id, String owner, BigDecimal balance) {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.ReactiveTransactionManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.reactive.TransactionalOperator;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Service
|
||||
public class AccountService {
|
||||
|
||||
private final DatabaseClient db;
|
||||
private final TransactionalOperator tx;
|
||||
|
||||
public AccountService(DatabaseClient db, ReactiveTransactionManager tm) {
|
||||
this.db = db;
|
||||
this.tx = TransactionalOperator.create(tm);
|
||||
}
|
||||
|
||||
private Mono<Long> adjust(long id, BigDecimal delta) {
|
||||
return db.sql("UPDATE account SET balance = balance + :d WHERE id = :id")
|
||||
.bind("d", delta).bind("id", id)
|
||||
.fetch().rowsUpdated();
|
||||
}
|
||||
|
||||
/** Declarative: the returned Mono is wrapped in a transaction by the proxy. */
|
||||
@Transactional
|
||||
public Mono<Void> transfer(long from, long to, BigDecimal amount) {
|
||||
return adjust(from, amount.negate())
|
||||
.then(adjust(to, amount))
|
||||
.then();
|
||||
}
|
||||
|
||||
/** Same work, no transaction. The debit commits on its own before the failure. */
|
||||
public Mono<Void> transferNoTx(long from, long to, BigDecimal amount) {
|
||||
return adjust(from, amount.negate())
|
||||
.then(Mono.<Long>error(new IllegalStateException("simulated failure after debit")))
|
||||
.then(adjust(to, amount))
|
||||
.then();
|
||||
}
|
||||
|
||||
/** Programmatic: TransactionalOperator wraps exactly the pipeline you hand it. */
|
||||
public Mono<Void> transferWithOperator(long from, long to, BigDecimal amount, boolean failAfterDebit) {
|
||||
Mono<Void> work = adjust(from, amount.negate())
|
||||
.then(failAfterDebit
|
||||
? Mono.<Long>error(new IllegalStateException("simulated failure after debit"))
|
||||
: Mono.just(0L))
|
||||
.then(adjust(to, amount))
|
||||
.then();
|
||||
return tx.transactional(work);
|
||||
}
|
||||
|
||||
/** Declarative, failing: the exception rolls the debit back. */
|
||||
@Transactional
|
||||
public Mono<Void> transferThenFail(long from, long to, BigDecimal amount) {
|
||||
return adjust(from, amount.negate())
|
||||
.then(Mono.<Long>error(new IllegalStateException("simulated failure after debit")))
|
||||
.then(adjust(to, amount))
|
||||
.then();
|
||||
}
|
||||
|
||||
public Mono<BigDecimal> balance(long id) {
|
||||
return db.sql("SELECT balance FROM account WHERE id = :id")
|
||||
.bind("id", id)
|
||||
.map(row -> row.get("balance", BigDecimal.class))
|
||||
.one();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
/**
|
||||
* A plain row. Note what is NOT here: no {@code List<PurchaseOrder>} field, no
|
||||
* @OneToMany. Spring Data R2DBC maps one row to one object and nothing else.
|
||||
*/
|
||||
@Table("customer")
|
||||
public record Customer(@Id Long id, String name, String email, String tier) {
|
||||
public static Customer ofNew(String name, String email, String tier) {
|
||||
return new Customer(null, name, email, tier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Three ways to get "customers with their orders" when there is no
|
||||
* relationship mapping. Each makes the cost of that choice visible.
|
||||
*/
|
||||
@Service
|
||||
public class CustomerOrderService {
|
||||
|
||||
private final CustomerRepository customers;
|
||||
private final PurchaseOrderRepository orders;
|
||||
private final DatabaseClient db;
|
||||
|
||||
public CustomerOrderService(CustomerRepository customers, PurchaseOrderRepository orders, DatabaseClient db) {
|
||||
this.customers = customers;
|
||||
this.orders = orders;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/** Way 1: 1 + N queries. Correct, readable, and one round trip per customer. */
|
||||
public Flux<CustomerWithOrders> loadNPlusOne() {
|
||||
return customers.findAll()
|
||||
.concatMap(c -> orders.findByCustomerId(c.id()).collectList()
|
||||
.map(list -> new CustomerWithOrders(c, list)));
|
||||
}
|
||||
|
||||
/** Way 2: two queries total, joined in memory. */
|
||||
public Flux<CustomerWithOrders> loadTwoQueries() {
|
||||
return customers.findAll().collectList().flatMapMany(cs ->
|
||||
orders.findAll().collectList().flatMapMany(os -> {
|
||||
Map<Long, List<PurchaseOrder>> byCustomer = new LinkedHashMap<>();
|
||||
os.forEach(o -> byCustomer.computeIfAbsent(o.customerId(), k -> new ArrayList<>()).add(o));
|
||||
return Flux.fromIterable(cs).map(c ->
|
||||
new CustomerWithOrders(c, byCustomer.getOrDefault(c.id(), List.of())));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Way 3: one JOIN with DatabaseClient, grouped by hand. */
|
||||
public Flux<CustomerWithOrders> loadWithJoin() {
|
||||
return db.sql("""
|
||||
SELECT c.id AS cid, c.name, c.email, c.tier,
|
||||
o.id AS oid, o.total
|
||||
FROM customer c LEFT JOIN purchase_order o ON o.customer_id = c.id
|
||||
ORDER BY c.id, o.id
|
||||
""")
|
||||
.map((row, meta) -> new Object[]{
|
||||
new Customer(row.get("cid", Long.class), row.get("name", String.class),
|
||||
row.get("email", String.class), row.get("tier", String.class)),
|
||||
row.get("oid", Long.class),
|
||||
row.get("total", BigDecimal.class)})
|
||||
.all()
|
||||
.bufferUntilChanged(r -> ((Customer) r[0]).id())
|
||||
.map(group -> {
|
||||
Customer c = (Customer) group.get(0)[0];
|
||||
List<PurchaseOrder> os = group.stream()
|
||||
.filter(r -> r[1] != null)
|
||||
.map(r -> new PurchaseOrder((Long) r[1], c.id(), (BigDecimal) r[2]))
|
||||
.toList();
|
||||
return new CustomerWithOrders(c, os);
|
||||
});
|
||||
}
|
||||
|
||||
/** DatabaseClient with a named parameter and an aggregate - SQL stays SQL. */
|
||||
public Mono<BigDecimal> totalSpent(String email) {
|
||||
return db.sql("""
|
||||
SELECT COALESCE(SUM(o.total), 0) AS spent
|
||||
FROM customer c JOIN purchase_order o ON o.customer_id = c.id
|
||||
WHERE c.email = :email
|
||||
""")
|
||||
.bind("email", email)
|
||||
.map(row -> row.get("spent", BigDecimal.class))
|
||||
.one();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface CustomerRepository extends ReactiveCrudRepository<Customer, Long> {
|
||||
|
||||
/** Derived query: Spring Data builds the SQL from the method name. */
|
||||
Flux<Customer> findByTier(String tier);
|
||||
|
||||
Mono<Customer> findByEmail(String email);
|
||||
|
||||
/** Hand-written SQL: R2DBC repositories take plain SQL, not JPQL. */
|
||||
@Query("SELECT * FROM customer WHERE lower(name) LIKE lower(:pattern) ORDER BY name")
|
||||
Flux<Customer> searchByName(String pattern);
|
||||
|
||||
/** Modifying query: must return {@code Mono<Long>} (rows updated), {@code Mono<Boolean>} or {@code Mono<Void>}. */
|
||||
@org.springframework.data.r2dbc.repository.Modifying
|
||||
@Query("UPDATE customer SET tier = :tier WHERE id = :id")
|
||||
Mono<Long> changeTier(Long id, String tier);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The aggregate you assemble yourself, because the mapping layer will not. */
|
||||
public record CustomerWithOrders(Customer customer, List<PurchaseOrder> orders) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** The child row holds only the foreign key value, never a Customer object. */
|
||||
@Table("purchase_order")
|
||||
public record PurchaseOrder(@Id Long id, @Column("customer_id") Long customerId, BigDecimal total) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public interface PurchaseOrderRepository extends ReactiveCrudRepository<PurchaseOrder, Long> {
|
||||
Flux<PurchaseOrder> findByCustomerId(Long customerId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class R2dbcDemoApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(R2dbcDemoApplication.class, args); }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Boot runs schema.sql through the R2DBC ConnectionFactory (not JDBC) at startup.
|
||||
spring.sql.init.mode=always
|
||||
spring.r2dbc.pool.initial-size=5
|
||||
spring.r2dbc.pool.max-size=20
|
||||
logging.level.org.springframework.r2dbc=INFO
|
||||
@@ -0,0 +1,22 @@
|
||||
DROP TABLE IF EXISTS purchase_order;
|
||||
DROP TABLE IF EXISTS account;
|
||||
DROP TABLE IF EXISTS customer;
|
||||
|
||||
CREATE TABLE customer (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
tier TEXT NOT NULL DEFAULT 'STANDARD'
|
||||
);
|
||||
|
||||
CREATE TABLE purchase_order (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
total NUMERIC(10,2) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE account (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0)
|
||||
);
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Every transcript in output/ is written by one of these tests, against a real
|
||||
* PostgreSQL 17 started by Testcontainers. The schema comes from
|
||||
* src/main/resources/schema.sql, run by Spring Boot at startup.
|
||||
*/
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
class R2dbcDemoTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
|
||||
|
||||
@Autowired CustomerRepository customers;
|
||||
@Autowired PurchaseOrderRepository orders;
|
||||
@Autowired CustomerOrderService service;
|
||||
@Autowired AccountService accounts;
|
||||
@Autowired DatabaseClient db;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
db.sql("TRUNCATE purchase_order, customer, account RESTART IDENTITY CASCADE").then().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaCreatedByBootAtStartup() {
|
||||
Transcript t = new Transcript("01-schema-init.txt");
|
||||
List<String> tables = db.sql("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' ORDER BY table_name""")
|
||||
.map(r -> r.get("table_name", String.class)).all().collectList().block();
|
||||
t.line("tables created by schema.sql: %s", tables);
|
||||
assertEquals(List.of("account", "customer", "purchase_order"), tables);
|
||||
t.write();
|
||||
}
|
||||
|
||||
@Test
|
||||
void crudWithStepVerifier() {
|
||||
Transcript t = new Transcript("02-crud.txt");
|
||||
|
||||
StepVerifier.create(customers.save(Customer.ofNew("Asha Rao", "[email protected]", "GOLD")))
|
||||
.assertNext(c -> { assertNotNull(c.id()); t.line("save -> id=%d name=%s", c.id(), c.name()); })
|
||||
.verifyComplete();
|
||||
|
||||
Customer asha = customers.findByEmail("[email protected]").block();
|
||||
StepVerifier.create(customers.findById(asha.id()))
|
||||
.assertNext(c -> t.line("findById -> %s", c))
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(customers.save(new Customer(asha.id(), "Asha Rao-Iyer", asha.email(), "GOLD")))
|
||||
.assertNext(c -> t.line("update -> name=%s (same id=%d, SQL UPDATE)", c.name(), c.id()))
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(customers.count()).expectNext(1L).verifyComplete();
|
||||
t.line("count -> 1");
|
||||
|
||||
StepVerifier.create(customers.deleteById(asha.id())).verifyComplete();
|
||||
StepVerifier.create(customers.count()).expectNext(0L).verifyComplete();
|
||||
t.line("deleteById-> count now 0");
|
||||
|
||||
StepVerifier.create(customers.findById(9999L)).verifyComplete();
|
||||
t.line("findById(9999) -> empty Mono (completes with no value, no exception)");
|
||||
t.write();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nothingHappensUntilYouSubscribe() {
|
||||
Transcript t = new Transcript("03-lazy-pipeline.txt");
|
||||
Mono<Customer> unsubscribed = customers.save(Customer.ofNew("Ghost", "[email protected]", "STANDARD"));
|
||||
long before = customers.count().block();
|
||||
t.line("built save(...) pipeline, never subscribed -> rows in table: %d", before);
|
||||
assertEquals(0L, before);
|
||||
unsubscribed.block();
|
||||
long after = customers.count().block();
|
||||
t.line("after .block() on the same pipeline -> rows in table: %d", after);
|
||||
assertEquals(1L, after);
|
||||
t.write();
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivedAndCustomQueries() {
|
||||
Transcript t = new Transcript("04-queries.txt");
|
||||
customers.saveAll(List.of(
|
||||
Customer.ofNew("Asha Rao", "[email protected]", "GOLD"),
|
||||
Customer.ofNew("Ravi Kulkarni", "[email protected]", "STANDARD"),
|
||||
Customer.ofNew("Meera Joshi", "[email protected]", "GOLD"))).blockLast();
|
||||
|
||||
StepVerifier.create(customers.findByTier("GOLD").map(Customer::name).collectList())
|
||||
.assertNext(n -> { t.line("findByTier(GOLD) -> %s", n); assertEquals(2, n.size()); })
|
||||
.verifyComplete();
|
||||
|
||||
StepVerifier.create(customers.searchByName("%RAO%").map(Customer::name))
|
||||
.expectNext("Asha Rao")
|
||||
.verifyComplete();
|
||||
t.line("@Query searchByName(%%RAO%%) -> [Asha Rao]");
|
||||
|
||||
Customer ravi = customers.findByEmail("[email protected]").block();
|
||||
StepVerifier.create(customers.changeTier(ravi.id(), "GOLD")).expectNext(1L).verifyComplete();
|
||||
t.line("@Modifying changeTier -> 1 row updated");
|
||||
StepVerifier.create(customers.findByTier("GOLD").count()).expectNext(3L).verifyComplete();
|
||||
t.line("GOLD customers now -> 3");
|
||||
|
||||
orders.saveAll(List.of(
|
||||
new PurchaseOrder(null, ravi.id(), new BigDecimal("120.50")),
|
||||
new PurchaseOrder(null, ravi.id(), new BigDecimal("79.50")))).blockLast();
|
||||
StepVerifier.create(service.totalSpent("[email protected]"))
|
||||
.assertNext(s -> { t.line("DatabaseClient totalSpent(ravi) -> %s", s); assertEquals(0, s.compareTo(new BigDecimal("200.00"))); })
|
||||
.verifyComplete();
|
||||
t.write();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRelationsMeansYouAssembleAggregatesYourself() {
|
||||
Transcript t = new Transcript("05-no-relations.txt");
|
||||
List<Customer> saved = customers.saveAll(List.of(
|
||||
Customer.ofNew("A", "[email protected]", "STANDARD"),
|
||||
Customer.ofNew("B", "[email protected]", "STANDARD"),
|
||||
Customer.ofNew("C", "[email protected]", "STANDARD"))).collectList().block();
|
||||
orders.saveAll(List.of(
|
||||
new PurchaseOrder(null, saved.get(0).id(), new BigDecimal("10.00")),
|
||||
new PurchaseOrder(null, saved.get(0).id(), new BigDecimal("20.00")),
|
||||
new PurchaseOrder(null, saved.get(1).id(), new BigDecimal("30.00")))).blockLast();
|
||||
|
||||
t.line("findAll() on Customer returns rows only. There is no orders field to be lazy or eager:");
|
||||
t.line(" %s", customers.findById(saved.get(0).id()).block());
|
||||
|
||||
List<String> nPlusOne = shape(service.loadNPlusOne());
|
||||
List<String> twoQ = shape(service.loadTwoQueries());
|
||||
List<String> join = shape(service.loadWithJoin());
|
||||
t.line("1+N queries (4 SQL statements for 3 customers) -> %s", nPlusOne);
|
||||
t.line("two queries (2 SQL statements) -> %s", twoQ);
|
||||
t.line("one LEFT JOIN, grouped by hand (1 statement) -> %s", join);
|
||||
assertEquals(nPlusOne, twoQ);
|
||||
assertEquals(nPlusOne, join);
|
||||
t.line("all three strategies return identical aggregates: true");
|
||||
t.write();
|
||||
}
|
||||
|
||||
private List<String> shape(Flux<CustomerWithOrders> f) {
|
||||
return f.map(cw -> cw.customer().name() + ":" + cw.orders().size()).collectList().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void transactionalRollsBackOnError() {
|
||||
Transcript t = new Transcript("06-transactions.txt");
|
||||
seedAccounts();
|
||||
|
||||
// 1. no transaction: the debit survives the failure
|
||||
StepVerifier.create(accounts.transferNoTx(1, 2, new BigDecimal("40.00")))
|
||||
.expectErrorMessage("simulated failure after debit").verify();
|
||||
BigDecimal a1 = accounts.balance(1).block(), b1 = accounts.balance(2).block();
|
||||
t.line("NO transaction, failure after debit -> alice=%s bob=%s (money vanished)", a1, b1);
|
||||
assertEquals(0, a1.compareTo(new BigDecimal("60.00")));
|
||||
assertEquals(0, b1.compareTo(new BigDecimal("50.00")));
|
||||
|
||||
// 2. @Transactional: rolled back
|
||||
seedAccounts();
|
||||
StepVerifier.create(accounts.transferThenFail(1, 2, new BigDecimal("40.00")))
|
||||
.expectErrorMessage("simulated failure after debit").verify();
|
||||
BigDecimal a2 = accounts.balance(1).block(), b2 = accounts.balance(2).block();
|
||||
t.line("@Transactional, failure after debit -> alice=%s bob=%s (rolled back)", a2, b2);
|
||||
assertEquals(0, a2.compareTo(new BigDecimal("100.00")));
|
||||
assertEquals(0, b2.compareTo(new BigDecimal("50.00")));
|
||||
|
||||
// 3. TransactionalOperator, failure
|
||||
StepVerifier.create(accounts.transferWithOperator(1, 2, new BigDecimal("40.00"), true))
|
||||
.expectErrorMessage("simulated failure after debit").verify();
|
||||
t.line("TransactionalOperator, failure -> alice=%s bob=%s (rolled back)",
|
||||
accounts.balance(1).block(), accounts.balance(2).block());
|
||||
assertEquals(0, accounts.balance(1).block().compareTo(new BigDecimal("100.00")));
|
||||
|
||||
// 4. success commits
|
||||
StepVerifier.create(accounts.transferWithOperator(1, 2, new BigDecimal("40.00"), false)).verifyComplete();
|
||||
t.line("TransactionalOperator, success -> alice=%s bob=%s (committed)",
|
||||
accounts.balance(1).block(), accounts.balance(2).block());
|
||||
assertEquals(0, accounts.balance(1).block().compareTo(new BigDecimal("60.00")));
|
||||
assertEquals(0, accounts.balance(2).block().compareTo(new BigDecimal("90.00")));
|
||||
|
||||
// 5. database-raised error (CHECK balance >= 0) on @Transactional
|
||||
seedAccounts();
|
||||
StepVerifier.create(accounts.transfer(1, 2, new BigDecimal("500.00")))
|
||||
.expectError(DataIntegrityViolationException.class).verify();
|
||||
t.line("@Transactional, CHECK(balance>=0) violated on debit -> DataIntegrityViolationException; alice=%s bob=%s",
|
||||
accounts.balance(1).block(), accounts.balance(2).block());
|
||||
|
||||
// 6. the credit side fails (unknown target is NOT an error: 0 rows updated)
|
||||
StepVerifier.create(accounts.transfer(1, 999, new BigDecimal("10.00"))).verifyComplete();
|
||||
t.line("transfer to a non-existent account -> no error at all; alice=%s (debited, credit hit 0 rows)",
|
||||
accounts.balance(1).block());
|
||||
t.write();
|
||||
}
|
||||
|
||||
private void seedAccounts() {
|
||||
db.sql("TRUNCATE account RESTART IDENTITY").then().block();
|
||||
db.sql("INSERT INTO account(owner, balance) VALUES ('alice', 100.00), ('bob', 50.00)").then().block();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.r2dbc.core.DatabaseClient;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* INDICATIVE ONLY. Same PostgreSQL container, same query, same pool size (20),
|
||||
* same logical concurrency (100): R2DBC through DatabaseClient versus plain JDBC
|
||||
* (HikariCP + JdbcTemplate) on virtual threads. Runs on a shared 2-vCPU sandbox
|
||||
* together with the database itself, so absolute numbers mean little; the point
|
||||
* is the shape: both are limited by the same 20 connections.
|
||||
*/
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
class ThroughputComparisonTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
|
||||
|
||||
@Autowired DatabaseClient db;
|
||||
|
||||
static final int ROWS = 1000, QUERIES = 4_000, CONCURRENCY = 100, POOL = 20, ROUNDS = 5;
|
||||
|
||||
@Test
|
||||
void compare() throws Exception {
|
||||
db.sql("INSERT INTO customer(name,email) SELECT 'n'||g, 'n'||g||'@x.io' FROM generate_series(1,%d) g".formatted(ROWS))
|
||||
.then().block();
|
||||
|
||||
HikariConfig cfg = new HikariConfig();
|
||||
cfg.setJdbcUrl(postgres.getJdbcUrl());
|
||||
cfg.setUsername(postgres.getUsername());
|
||||
cfg.setPassword(postgres.getPassword());
|
||||
cfg.setMaximumPoolSize(POOL);
|
||||
cfg.setMinimumIdle(POOL);
|
||||
|
||||
List<Double> r2 = new ArrayList<>(), jd = new ArrayList<>();
|
||||
try (HikariDataSource ds = new HikariDataSource(cfg)) {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(ds);
|
||||
runR2dbc(); // warm-up, discarded
|
||||
runJdbc(jdbc); // warm-up, discarded
|
||||
for (int i = 0; i < ROUNDS; i++) {
|
||||
r2.add(runR2dbc());
|
||||
jd.add(runJdbc(jdbc));
|
||||
}
|
||||
}
|
||||
Collections.sort(r2);
|
||||
Collections.sort(jd);
|
||||
|
||||
Transcript t = new Transcript("07-throughput-indicative.txt");
|
||||
t.line("INDICATIVE, shared 2-vCPU sandbox, database in a container on the same host.");
|
||||
t.line("%d x 'SELECT ... WHERE id = ?' per round, %d in flight, pool size %d, %d rounds after 1 warm-up each.",
|
||||
QUERIES, CONCURRENCY, POOL, ROUNDS);
|
||||
t.line("queries/second, sorted:");
|
||||
t.line(" R2DBC DatabaseClient : %s median=%.0f", fmt(r2), r2.get(ROUNDS / 2));
|
||||
t.line(" JDBC + virtual threads : %s median=%.0f", fmt(jd), jd.get(ROUNDS / 2));
|
||||
t.write();
|
||||
}
|
||||
|
||||
private static String fmt(List<Double> xs) {
|
||||
return xs.stream().map(d -> String.format("%.0f", d)).toList().toString();
|
||||
}
|
||||
|
||||
private double runR2dbc() {
|
||||
long t0 = System.nanoTime();
|
||||
Long n = Flux.range(0, QUERIES)
|
||||
.flatMap(i -> db.sql("SELECT name FROM customer WHERE id = :id")
|
||||
.bind("id", 1L + (i % ROWS))
|
||||
.map(r -> r.get("name", String.class)).one(), CONCURRENCY)
|
||||
.count().block();
|
||||
assertEquals(QUERIES, n);
|
||||
return QUERIES / ((System.nanoTime() - t0) / 1e9);
|
||||
}
|
||||
|
||||
private double runJdbc(JdbcTemplate jdbc) throws Exception {
|
||||
long t0 = System.nanoTime();
|
||||
java.util.concurrent.Semaphore inFlight = new java.util.concurrent.Semaphore(CONCURRENCY);
|
||||
List<Future<String>> fs = new ArrayList<>(QUERIES);
|
||||
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
for (int i = 0; i < QUERIES; i++) {
|
||||
long id = 1L + (i % ROWS);
|
||||
inFlight.acquire();
|
||||
fs.add(ex.submit(() -> {
|
||||
try {
|
||||
return jdbc.queryForObject("SELECT name FROM customer WHERE id = ?", String.class, id);
|
||||
} finally { inFlight.release(); }
|
||||
}));
|
||||
}
|
||||
for (Future<String> f : fs) f.get();
|
||||
}
|
||||
return QUERIES / ((System.nanoTime() - t0) / 1e9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ankurm.r2dbc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Writes output/NN-*.txt while a test asserts the same numbers. */
|
||||
final class Transcript {
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final Path file;
|
||||
|
||||
Transcript(String name) { this.file = Path.of("output/" + name); }
|
||||
|
||||
Transcript line(String fmt, Object... args) {
|
||||
String s = fmt.formatted(args);
|
||||
lines.add(s);
|
||||
System.out.println(s);
|
||||
return this;
|
||||
}
|
||||
|
||||
void write() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, String.join("\n", lines) + "\n");
|
||||
} catch (IOException e) { throw new RuntimeException(e); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user