Add locking module: optimistic vs pessimistic locking on Hibernate 7.4.14.Final
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YPgGH6aMbSLANL3E2HHmS6
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# locking
|
||||
|
||||
Companion module for the ankurm.com article on **optimistic vs pessimistic locking in Hibernate 7**. Two threads, one
|
||||
in-memory H2 database, plain Hibernate (no Spring). Each test writes what it observed to `output/`.
|
||||
|
||||
## Versions
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| Hibernate ORM | `7.4.14.Final` | latest 7.x GA on Maven Central on 2026-10-25 (`8.0.0.Beta3` is also listed; it is not GA) |
|
||||
| Jakarta Persistence | `3.2.0` | |
|
||||
| H2 | `2.4.240` | in memory, `OPTIMIZE_REUSE_RESULTS=0` (see `Db.java`) |
|
||||
| JUnit Jupiter / AssertJ | `6.0.3` / `3.27.7` | |
|
||||
| Java | `25` (Temurin 25.0.5+8) | |
|
||||
|
||||
This module has its own `pom.xml` and does not use the Spring Boot parent of the main `hibernate-demo` project.
|
||||
All locking behaviour here is H2's. `SKIP LOCKED`, `NOWAIT` and deadlock handling differ between databases, so check
|
||||
your own.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
./scripts/run-all.sh
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| `01-lost-update.txt` | two transactions read 100, both deposit, one deposit is lost (no `@Version`) |
|
||||
| `02-version-stops-it.txt` | the same race with `@Version`: the second commit fails with `OptimisticLockException` |
|
||||
| `03-retry-loop.txt` | six threads, thirty increments, retry on `OptimisticLockException`; the final balance is exact |
|
||||
| `04-pessimistic-write.txt` | `PESSIMISTIC_WRITE` makes the second transaction wait; SQL uses `for update` |
|
||||
| `05-no-wait.txt` | `Timeouts.NO_WAIT` fails at once (`for update nowait`) |
|
||||
| `06-skip-locked.txt` | two workers claim different tickets (`for update skip locked`) |
|
||||
| `07-deadlock.txt` | opposite lock order deadlocks; the same order for everyone does not |
|
||||
@@ -0,0 +1,7 @@
|
||||
start balance 100; transaction A deposits 10, transaction B deposits 20
|
||||
both read 100 before either writes; A commits first, then B
|
||||
final balance: 120 (a correct result would be 130)
|
||||
|
||||
update statements Hibernate sent:
|
||||
update Account set balance=?,owner=? where id=?
|
||||
update Account set balance=?,owner=? where id=?
|
||||
@@ -0,0 +1,9 @@
|
||||
same race, entity has @Version
|
||||
A: committed. B: failed
|
||||
B exception: jakarta.persistence.OptimisticLockException
|
||||
caused by: org.hibernate.StaleObjectStateException
|
||||
final balance: 110, version: 1
|
||||
|
||||
update statements Hibernate sent:
|
||||
update VersionedAccount set balance=?,owner=?,version=? where id=? and version=?
|
||||
update VersionedAccount set balance=?,owner=?,version=? where id=? and version=?
|
||||
@@ -0,0 +1,5 @@
|
||||
6 threads x 5 deposits of 1, each retried on OptimisticLockException
|
||||
final balance: 30 (expected 30)
|
||||
version: 30
|
||||
retries happened: true
|
||||
retry count in this run: 27 (varies from run to run)
|
||||
@@ -0,0 +1,8 @@
|
||||
A locks row 1 with PESSIMISTIC_WRITE, holds it 600 ms, deposits 10
|
||||
B asks for the same lock, then deposits 20
|
||||
B waited at least 400 ms for the lock: true
|
||||
final balance: 130 (correct: 130)
|
||||
|
||||
select statements Hibernate sent (one per transaction):
|
||||
select a1_0.id,a1_0.balance,a1_0.owner from Account a1_0 where a1_0.id=? for update
|
||||
select a1_0.id,a1_0.balance,a1_0.owner from Account a1_0 where a1_0.id=? for update
|
||||
@@ -0,0 +1,8 @@
|
||||
A holds the lock; B asks with Timeouts.NO_WAIT
|
||||
B exception: org.hibernate.exception.LockTimeoutException
|
||||
caused by org.h2.jdbc.JdbcSQLTimeoutException
|
||||
H2 message: Timeout trying to lock table "ACCOUNT"; SQL statement:
|
||||
caused by org.h2.mvstore.MVStoreException
|
||||
|
||||
select statements B sent:
|
||||
select a1_0.id,a1_0.balance,a1_0.owner from Account a1_0 where a1_0.id=? for update nowait
|
||||
@@ -0,0 +1,7 @@
|
||||
5 tickets, all NEW. Worker A claims 2 and keeps its transaction open.
|
||||
Worker B claims 2 with SKIP LOCKED.
|
||||
A got tickets: [1, 2]
|
||||
B got tickets: [3, 4]
|
||||
|
||||
select statements B sent:
|
||||
select t1_0.id,t1_0.claimedBy,t1_0.status from Ticket t1_0 where t1_0.status='NEW' order by t1_0.id fetch first ? rows only for update skip locked
|
||||
@@ -0,0 +1,10 @@
|
||||
opposite lock order (A: 1 then 2, B: 2 then 1)
|
||||
transactions that failed: 1
|
||||
jakarta.persistence.PessimisticLockException
|
||||
caused by org.hibernate.exception.LockAcquisitionException
|
||||
caused by org.h2.jdbc.JdbcSQLTransactionRollbackException
|
||||
H2 message: Deadlock detected. The current transaction was rolled back. Details: "ACCOUNT"; SQL statement:
|
||||
caused by org.h2.mvstore.MVStoreException
|
||||
|
||||
same lock order for both (always the lower id first)
|
||||
transactions that failed: 0
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>locking</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>locking</name>
|
||||
<description>Hibernate 7 optimistic vs pessimistic locking: lost updates, @Version, retries, PESSIMISTIC_WRITE, SKIP LOCKED and deadlocks, run with plain Hibernate (no Spring)</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<hibernate.version>7.4.14.Final</hibernate.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-core</artifactId>
|
||||
<version>${hibernate.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.persistence</groupId>
|
||||
<artifactId>jakarta.persistence-api</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.4.240</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>6.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/. Needs JDK 25 and Maven 3.9 with access to Maven Central.
|
||||
# The tests assert the facts they print. The only figure that changes between runs is the retry count in
|
||||
# output/03-retry-loop.txt.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B clean test
|
||||
ls -1 output
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
/** No version column: two transactions can silently overwrite each other. */
|
||||
@Entity
|
||||
public class Account {
|
||||
|
||||
@Id
|
||||
public Long id;
|
||||
public String owner;
|
||||
public long balance;
|
||||
|
||||
protected Account() {
|
||||
}
|
||||
|
||||
public Account(Long id, String owner, long balance) {
|
||||
this.id = id;
|
||||
this.owner = owner;
|
||||
this.balance = balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
|
||||
/** A unit of work in a database-backed queue. */
|
||||
@Entity
|
||||
public class Ticket {
|
||||
|
||||
@Id
|
||||
public Long id;
|
||||
public String status;
|
||||
public String claimedBy;
|
||||
|
||||
protected Ticket() {
|
||||
}
|
||||
|
||||
public Ticket(Long id, String status) {
|
||||
this.id = id;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
/** Same shape as {@link Account} plus a {@code @Version} column. */
|
||||
@Entity
|
||||
public class VersionedAccount {
|
||||
|
||||
@Id
|
||||
public Long id;
|
||||
public String owner;
|
||||
public long balance;
|
||||
|
||||
@Version
|
||||
public long version;
|
||||
|
||||
protected VersionedAccount() {
|
||||
}
|
||||
|
||||
public VersionedAccount(Long id, String owner, long balance) {
|
||||
this.id = id;
|
||||
this.owner = owner;
|
||||
this.balance = balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistry;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.cfg.AvailableSettings;
|
||||
import org.hibernate.resource.jdbc.spi.StatementInspector;
|
||||
import org.hibernate.SessionFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Plain-Hibernate bootstrap against in-memory H2, recording every SQL string Hibernate prepares. */
|
||||
final class Db implements AutoCloseable {
|
||||
|
||||
private static final AtomicInteger SEQ = new AtomicInteger();
|
||||
|
||||
/** Every statement Hibernate prepared since the last {@link #clear()}. */
|
||||
final List<String> sql = java.util.Collections.synchronizedList(new ArrayList<>());
|
||||
final SessionFactory sessionFactory;
|
||||
private final String url;
|
||||
|
||||
Db(Class<?>... entities) {
|
||||
// OPTIMIZE_REUSE_RESULTS=0: by default H2 may hand back the previous result of an identical query when no
|
||||
// table changed. Switched off so every query in these tests really reaches the table.
|
||||
this.url = "jdbc:h2:mem:lck" + SEQ.incrementAndGet() + ";DB_CLOSE_DELAY=-1;OPTIMIZE_REUSE_RESULTS=0";
|
||||
StatementInspector inspector = s -> {
|
||||
sql.add(s);
|
||||
return s;
|
||||
};
|
||||
StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
|
||||
.applySetting(AvailableSettings.JAKARTA_JDBC_URL, url)
|
||||
.applySetting(AvailableSettings.JAKARTA_JDBC_USER, "sa")
|
||||
.applySetting(AvailableSettings.JAKARTA_JDBC_PASSWORD, "")
|
||||
.applySetting(AvailableSettings.HBM2DDL_AUTO, "create-drop")
|
||||
.applySetting(AvailableSettings.STATEMENT_INSPECTOR, inspector)
|
||||
.build();
|
||||
MetadataSources sources = new MetadataSources(registry);
|
||||
for (Class<?> e : entities) {
|
||||
sources.addAnnotatedClass(e);
|
||||
}
|
||||
this.sessionFactory = sources.buildMetadata().buildSessionFactory();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
sql.clear();
|
||||
}
|
||||
|
||||
/** Raw JDBC against the same in-memory database. */
|
||||
List<String> jdbc(String query) {
|
||||
List<String> rows = new ArrayList<>();
|
||||
try (Connection c = java.sql.DriverManager.getConnection(url, "sa", "");
|
||||
Statement st = c.createStatement();
|
||||
ResultSet rs = st.executeQuery(query)) {
|
||||
int cols = rs.getMetaData().getColumnCount();
|
||||
while (rs.next()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 1; i <= cols; i++) {
|
||||
if (i > 1) sb.append(" | ");
|
||||
sb.append(rs.getString(i));
|
||||
}
|
||||
rows.add(sb.toString());
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Raw JDBC statement; returns the database's error message if it is rejected, or "accepted". */
|
||||
String tryExec(String statement) {
|
||||
try (Connection c = java.sql.DriverManager.getConnection(url, "sa", "");
|
||||
Statement st = c.createStatement()) {
|
||||
st.executeUpdate(statement);
|
||||
return "accepted";
|
||||
} catch (SQLException e) {
|
||||
return e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
sessionFactory.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import jakarta.persistence.OptimisticLockException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Lost update without a version, the same race with @Version, and the retry loop that makes it usable. */
|
||||
class OptimisticTest {
|
||||
|
||||
@Test
|
||||
void lostUpdateWithoutVersion() {
|
||||
try (Db db = new Db(Account.class); Transcript t = new Transcript("01-lost-update")) {
|
||||
db.sessionFactory.inTransaction(s -> s.persist(new Account(1L, "alice", 100)));
|
||||
db.clear();
|
||||
|
||||
CountDownLatch bothRead = new CountDownLatch(2);
|
||||
CountDownLatch firstCommitted = new CountDownLatch(1);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
Future<?> a = pool.submit(() -> db.sessionFactory.inTransaction(s -> {
|
||||
Account acc = s.find(Account.class, 1L);
|
||||
bothRead.countDown();
|
||||
await(bothRead);
|
||||
acc.balance += 10;
|
||||
}));
|
||||
Future<?> b = pool.submit(() -> {
|
||||
db.sessionFactory.inTransaction(s -> {
|
||||
Account acc = s.find(Account.class, 1L);
|
||||
bothRead.countDown();
|
||||
await(bothRead);
|
||||
await(firstCommitted);
|
||||
acc.balance += 20;
|
||||
});
|
||||
});
|
||||
get(a);
|
||||
firstCommitted.countDown();
|
||||
get(b);
|
||||
pool.shutdown();
|
||||
|
||||
long balance = db.sessionFactory.fromSession(s -> s.find(Account.class, 1L).balance);
|
||||
t.line("start balance 100; transaction A deposits 10, transaction B deposits 20");
|
||||
t.line("both read 100 before either writes; A commits first, then B");
|
||||
t.line("final balance: " + balance + " (a correct result would be 130)");
|
||||
t.blank().line("update statements Hibernate sent:");
|
||||
db.sql.stream().filter(x -> x.startsWith("update")).forEach(t::line);
|
||||
assertThat(balance).isEqualTo(120);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionStopsTheLostUpdate() {
|
||||
try (Db db = new Db(VersionedAccount.class); Transcript t = new Transcript("02-version-stops-it")) {
|
||||
db.sessionFactory.inTransaction(s -> s.persist(new VersionedAccount(1L, "alice", 100)));
|
||||
db.clear();
|
||||
|
||||
CountDownLatch bothRead = new CountDownLatch(2);
|
||||
CountDownLatch firstCommitted = new CountDownLatch(1);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
Future<?> a = pool.submit(() -> db.sessionFactory.inTransaction(s -> {
|
||||
VersionedAccount acc = s.find(VersionedAccount.class, 1L);
|
||||
bothRead.countDown();
|
||||
await(bothRead);
|
||||
acc.balance += 10;
|
||||
}));
|
||||
Future<Throwable> b = pool.submit(() -> {
|
||||
try {
|
||||
db.sessionFactory.inTransaction(s -> {
|
||||
VersionedAccount acc = s.find(VersionedAccount.class, 1L);
|
||||
bothRead.countDown();
|
||||
await(bothRead);
|
||||
await(firstCommitted);
|
||||
acc.balance += 20;
|
||||
});
|
||||
return null;
|
||||
} catch (Throwable e) {
|
||||
return e;
|
||||
}
|
||||
});
|
||||
get(a);
|
||||
firstCommitted.countDown();
|
||||
Throwable failure;
|
||||
try {
|
||||
failure = b.get();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
pool.shutdown();
|
||||
|
||||
VersionedAccount after = db.sessionFactory.fromSession(s -> s.find(VersionedAccount.class, 1L));
|
||||
t.line("same race, entity has @Version");
|
||||
t.line("A: committed. B: " + (failure == null ? "committed" : "failed"));
|
||||
t.line("B exception: " + failure.getClass().getName());
|
||||
t.line("caused by: " + failure.getCause().getClass().getName());
|
||||
t.line("final balance: " + after.balance + ", version: " + after.version);
|
||||
t.blank().line("update statements Hibernate sent:");
|
||||
db.sql.stream().filter(x -> x.startsWith("update")).forEach(t::line);
|
||||
assertThat(failure).isInstanceOf(OptimisticLockException.class);
|
||||
assertThat(after.balance).isEqualTo(110);
|
||||
assertThat(after.version).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryLoopConverges() {
|
||||
try (Db db = new Db(VersionedAccount.class); Transcript t = new Transcript("03-retry-loop")) {
|
||||
db.sessionFactory.inTransaction(s -> s.persist(new VersionedAccount(1L, "alice", 0)));
|
||||
int threads = 6;
|
||||
int depositsEach = 5;
|
||||
AtomicInteger retries = new AtomicInteger();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < threads; i++) {
|
||||
futures.add(pool.submit(() -> {
|
||||
for (int d = 0; d < depositsEach; d++) {
|
||||
while (true) {
|
||||
try {
|
||||
db.sessionFactory.inTransaction(s -> s.find(VersionedAccount.class, 1L).balance += 1);
|
||||
break;
|
||||
} catch (OptimisticLockException e) {
|
||||
retries.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
futures.forEach(OptimisticTest::get);
|
||||
pool.shutdown();
|
||||
VersionedAccount after = db.sessionFactory.fromSession(s -> s.find(VersionedAccount.class, 1L));
|
||||
t.line(threads + " threads x " + depositsEach + " deposits of 1, each retried on OptimisticLockException");
|
||||
t.line("final balance: " + after.balance + " (expected " + threads * depositsEach + ")");
|
||||
t.line("version: " + after.version);
|
||||
t.line("retries happened: " + (retries.get() > 0));
|
||||
t.line("retry count in this run: " + retries.get() + " (varies from run to run)");
|
||||
assertThat(after.balance).isEqualTo(threads * depositsEach);
|
||||
assertThat(after.version).isEqualTo(threads * depositsEach);
|
||||
}
|
||||
}
|
||||
|
||||
static void await(CountDownLatch l) {
|
||||
try {
|
||||
l.await();
|
||||
} catch (InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
static void get(Future<?> f) {
|
||||
try {
|
||||
f.get();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import static com.ankurm.locking.OptimisticTest.await;
|
||||
import static com.ankurm.locking.OptimisticTest.get;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.hibernate.Timeouts;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** PESSIMISTIC_WRITE: waiting, giving up, skipping locked rows, and a deadlock. */
|
||||
class PessimisticTest {
|
||||
|
||||
private static String describe(Throwable e) {
|
||||
StringBuilder sb = new StringBuilder(e.getClass().getName());
|
||||
for (Throwable c = e.getCause(); c != null; c = c.getCause()) {
|
||||
sb.append("\n caused by ").append(c.getClass().getName());
|
||||
if (c.getClass().getName().startsWith("org.h2.jdbc")) {
|
||||
sb.append("\n H2 message: ").append(c.getMessage().lines().findFirst().orElse(""));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondWriterWaitsForTheFirst() {
|
||||
try (Db db = new Db(Account.class); Transcript t = new Transcript("04-pessimistic-write")) {
|
||||
db.sessionFactory.inTransaction(s -> s.persist(new Account(1L, "alice", 100)));
|
||||
db.clear();
|
||||
CountDownLatch locked = new CountDownLatch(1);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
Future<?> a = pool.submit(() -> db.sessionFactory.inTransaction(s -> {
|
||||
Account acc = s.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);
|
||||
locked.countDown();
|
||||
sleep(600);
|
||||
acc.balance += 10;
|
||||
}));
|
||||
long[] waitedMillis = new long[1];
|
||||
Future<?> b = pool.submit(() -> {
|
||||
await(locked);
|
||||
long start = System.nanoTime();
|
||||
db.sessionFactory.inTransaction(s -> {
|
||||
Account acc = s.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);
|
||||
waitedMillis[0] = (System.nanoTime() - start) / 1_000_000;
|
||||
acc.balance += 20;
|
||||
});
|
||||
});
|
||||
get(a);
|
||||
get(b);
|
||||
pool.shutdown();
|
||||
List<String> sent = new ArrayList<>(db.sql);
|
||||
long balance = db.sessionFactory.fromSession(s -> s.find(Account.class, 1L).balance);
|
||||
t.line("A locks row 1 with PESSIMISTIC_WRITE, holds it 600 ms, deposits 10");
|
||||
t.line("B asks for the same lock, then deposits 20");
|
||||
t.line("B waited at least 400 ms for the lock: " + (waitedMillis[0] >= 400));
|
||||
t.line("final balance: " + balance + " (correct: 130)");
|
||||
t.blank().line("select statements Hibernate sent (one per transaction):");
|
||||
sent.stream().filter(x -> x.startsWith("select")).forEach(t::line);
|
||||
assertThat(waitedMillis[0]).isGreaterThanOrEqualTo(400);
|
||||
assertThat(balance).isEqualTo(130);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noWaitGivesUpImmediately() {
|
||||
try (Db db = new Db(Account.class); Transcript t = new Transcript("05-no-wait")) {
|
||||
db.sessionFactory.inTransaction(s -> s.persist(new Account(1L, "alice", 100)));
|
||||
db.clear();
|
||||
CountDownLatch locked = new CountDownLatch(1);
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
Future<?> a = pool.submit(() -> db.sessionFactory.inTransaction(s -> {
|
||||
s.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);
|
||||
locked.countDown();
|
||||
await(done);
|
||||
}));
|
||||
await(locked);
|
||||
db.clear();
|
||||
Throwable failure = null;
|
||||
try {
|
||||
db.sessionFactory.inTransaction(s ->
|
||||
s.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE, Timeouts.NO_WAIT));
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
done.countDown();
|
||||
get(a);
|
||||
pool.shutdown();
|
||||
t.line("A holds the lock; B asks with Timeouts.NO_WAIT");
|
||||
t.line("B exception: " + (failure == null ? "none" : describe(failure)));
|
||||
t.blank().line("select statements B sent:");
|
||||
db.sql.stream().filter(x -> x.startsWith("select")).forEach(t::line);
|
||||
assertThat(failure).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipLockedGivesEachWorkerDifferentRows() {
|
||||
try (Db db = new Db(Ticket.class); Transcript t = new Transcript("06-skip-locked")) {
|
||||
db.sessionFactory.inTransaction(s -> {
|
||||
for (long i = 1; i <= 5; i++) {
|
||||
s.persist(new Ticket(i, "NEW"));
|
||||
}
|
||||
});
|
||||
db.clear();
|
||||
CountDownLatch aHolds = new CountDownLatch(1);
|
||||
CountDownLatch bDone = new CountDownLatch(1);
|
||||
List<Long> claimedByA = new ArrayList<>();
|
||||
List<Long> claimedByB = new ArrayList<>();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
Future<?> a = pool.submit(() -> db.sessionFactory.inTransaction(s -> {
|
||||
claim(s, 2).forEach(tk -> claimedByA.add(tk.id));
|
||||
aHolds.countDown();
|
||||
await(bDone);
|
||||
}));
|
||||
await(aHolds);
|
||||
db.sql.clear();
|
||||
db.sessionFactory.inTransaction(s -> claim(s, 2).forEach(tk -> claimedByB.add(tk.id)));
|
||||
List<String> bSql = new ArrayList<>(db.sql);
|
||||
bDone.countDown();
|
||||
get(a);
|
||||
pool.shutdown();
|
||||
t.line("5 tickets, all NEW. Worker A claims 2 and keeps its transaction open.");
|
||||
t.line("Worker B claims 2 with SKIP LOCKED.");
|
||||
t.line("A got tickets: " + claimedByA);
|
||||
t.line("B got tickets: " + claimedByB);
|
||||
t.blank().line("select statements B sent:");
|
||||
bSql.stream().filter(x -> x.startsWith("select")).forEach(t::line);
|
||||
assertThat(claimedByA).containsExactly(1L, 2L);
|
||||
assertThat(claimedByB).containsExactly(3L, 4L);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Ticket> claim(org.hibernate.Session s, int n) {
|
||||
List<Ticket> rows = s.createQuery("from Ticket t where t.status = 'NEW' order by t.id", Ticket.class)
|
||||
.setMaxResults(n)
|
||||
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
|
||||
.setHint("jakarta.persistence.lock.timeout", Timeouts.SKIP_LOCKED_MILLI)
|
||||
.getResultList();
|
||||
rows.forEach(tk -> tk.status = "CLAIMED");
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadlockAndTheFix() {
|
||||
try (Db db = new Db(Account.class); Transcript t = new Transcript("07-deadlock")) {
|
||||
db.sessionFactory.inTransaction(s -> {
|
||||
s.persist(new Account(1L, "alice", 100));
|
||||
s.persist(new Account(2L, "bob", 100));
|
||||
});
|
||||
|
||||
// opposite lock order: A takes 1 then 2, B takes 2 then 1
|
||||
List<Throwable> failures = transfer(db, true, 1L, 2L, 2L, 1L);
|
||||
t.line("opposite lock order (A: 1 then 2, B: 2 then 1)");
|
||||
t.line("transactions that failed: " + failures.size());
|
||||
failures.forEach(f -> t.line(" " + describe(f)));
|
||||
|
||||
// same order for everyone: lowest id first
|
||||
List<Throwable> ordered = transfer(db, false, 1L, 2L, 1L, 2L);
|
||||
t.blank().line("same lock order for both (always the lower id first)");
|
||||
t.line("transactions that failed: " + ordered.size());
|
||||
assertThat(ordered).isEmpty();
|
||||
assertThat(failures).isNotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Two transactions; each locks its first id (optionally waiting until both hold one), then its second id. */
|
||||
private static List<Throwable> transfer(Db db, boolean syncAfterFirstLock, long aFirst, long aSecond, long bFirst, long bSecond) {
|
||||
CountDownLatch bothHoldFirst = new CountDownLatch(2);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
List<Future<Throwable>> results = new ArrayList<>();
|
||||
long[][] plans = {{aFirst, aSecond}, {bFirst, bSecond}};
|
||||
for (long[] plan : plans) {
|
||||
results.add(pool.submit(() -> {
|
||||
try {
|
||||
db.sessionFactory.inTransaction(s -> {
|
||||
s.find(Account.class, plan[0], LockModeType.PESSIMISTIC_WRITE);
|
||||
if (syncAfterFirstLock) {
|
||||
bothHoldFirst.countDown();
|
||||
try {
|
||||
bothHoldFirst.await(3, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
s.find(Account.class, plan[1], LockModeType.PESSIMISTIC_WRITE);
|
||||
});
|
||||
return null;
|
||||
} catch (Throwable e) {
|
||||
return e;
|
||||
}
|
||||
}));
|
||||
}
|
||||
List<Throwable> failures = new ArrayList<>();
|
||||
for (Future<Throwable> f : results) {
|
||||
try {
|
||||
Throwable e = f.get();
|
||||
if (e != null) {
|
||||
failures.add(e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
pool.shutdown();
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static void sleep(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.locking;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Collects lines and writes them to output/NN-name.txt when closed. The tests assert the same
|
||||
* numbers they print, so every figure in the transcripts is an assertion that fails the build
|
||||
* when it stops being true.
|
||||
*/
|
||||
final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path file;
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
|
||||
Transcript(String name) {
|
||||
this.file = Path.of("output", name + ".txt");
|
||||
}
|
||||
|
||||
Transcript line(String s) {
|
||||
lines.add(s);
|
||||
return this;
|
||||
}
|
||||
|
||||
Transcript blank() {
|
||||
lines.add("");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Hibernate emits one long line per statement; break it at the keywords so a reader can follow the joins. */
|
||||
Transcript sql(String ignoredLabel, String sql) {
|
||||
String pretty = sql
|
||||
.replace(" union all select", "\nunion all\nselect")
|
||||
.replaceAll(" (left join|inner join|cross join|join|where|group by|order by) ", "\n $1 ")
|
||||
.replaceAll(" from ", "\nfrom ");
|
||||
lines.add(pretty + ";");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.write(file, lines);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user