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:
2026-10-25 17:01:49 +00:00
co-authored by Claude Sonnet 5.5
parent 807f681ed1
commit 357ba84e5c
18 changed files with 758 additions and 0 deletions
+1
View File
@@ -343,3 +343,4 @@ Modules with their own `pom.xml` that run plain Hibernate without the Spring Boo
|---|---| |---|---|
| [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks | | [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks |
| [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) | | [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) |
| [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) |
+36
View File
@@ -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 |
+7
View File
@@ -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=?
+9
View File
@@ -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=?
+5
View File
@@ -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)
+8
View File
@@ -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
+8
View File
@@ -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
+7
View File
@@ -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
+10
View File
@@ -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
+57
View File
@@ -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>
+8
View File
@@ -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);
}
}
}