From 357ba84e5c266459db68faaefc37f214e59287ca Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sun, 25 Oct 2026 17:01:49 +0000 Subject: [PATCH] Add locking module: optimistic vs pessimistic locking on Hibernate 7.4.14.Final Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01YPgGH6aMbSLANL3E2HHmS6 --- README.md | 1 + locking/README.md | 36 +++ locking/output/01-lost-update.txt | 7 + locking/output/02-version-stops-it.txt | 9 + locking/output/03-retry-loop.txt | 5 + locking/output/04-pessimistic-write.txt | 8 + locking/output/05-no-wait.txt | 8 + locking/output/06-skip-locked.txt | 7 + locking/output/07-deadlock.txt | 10 + locking/pom.xml | 57 +++++ locking/scripts/run-all.sh | 8 + .../main/java/com/ankurm/locking/Account.java | 23 ++ .../main/java/com/ankurm/locking/Ticket.java | 22 ++ .../com/ankurm/locking/VersionedAccount.java | 27 +++ .../src/test/java/com/ankurm/locking/Db.java | 93 ++++++++ .../com/ankurm/locking/OptimisticTest.java | 162 +++++++++++++ .../com/ankurm/locking/PessimisticTest.java | 223 ++++++++++++++++++ .../java/com/ankurm/locking/Transcript.java | 52 ++++ 18 files changed, 758 insertions(+) create mode 100644 locking/README.md create mode 100644 locking/output/01-lost-update.txt create mode 100644 locking/output/02-version-stops-it.txt create mode 100644 locking/output/03-retry-loop.txt create mode 100644 locking/output/04-pessimistic-write.txt create mode 100644 locking/output/05-no-wait.txt create mode 100644 locking/output/06-skip-locked.txt create mode 100644 locking/output/07-deadlock.txt create mode 100644 locking/pom.xml create mode 100755 locking/scripts/run-all.sh create mode 100644 locking/src/main/java/com/ankurm/locking/Account.java create mode 100644 locking/src/main/java/com/ankurm/locking/Ticket.java create mode 100644 locking/src/main/java/com/ankurm/locking/VersionedAccount.java create mode 100644 locking/src/test/java/com/ankurm/locking/Db.java create mode 100644 locking/src/test/java/com/ankurm/locking/OptimisticTest.java create mode 100644 locking/src/test/java/com/ankurm/locking/PessimisticTest.java create mode 100644 locking/src/test/java/com/ankurm/locking/Transcript.java diff --git a/README.md b/README.md index 6184857..66d86e1 100755 --- a/README.md +++ b/README.md @@ -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 | | [`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`) | diff --git a/locking/README.md b/locking/README.md new file mode 100644 index 0000000..b02da7f --- /dev/null +++ b/locking/README.md @@ -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 | diff --git a/locking/output/01-lost-update.txt b/locking/output/01-lost-update.txt new file mode 100644 index 0000000..a59b652 --- /dev/null +++ b/locking/output/01-lost-update.txt @@ -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=? diff --git a/locking/output/02-version-stops-it.txt b/locking/output/02-version-stops-it.txt new file mode 100644 index 0000000..2eaa4d7 --- /dev/null +++ b/locking/output/02-version-stops-it.txt @@ -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=? diff --git a/locking/output/03-retry-loop.txt b/locking/output/03-retry-loop.txt new file mode 100644 index 0000000..d17f771 --- /dev/null +++ b/locking/output/03-retry-loop.txt @@ -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) diff --git a/locking/output/04-pessimistic-write.txt b/locking/output/04-pessimistic-write.txt new file mode 100644 index 0000000..a5c8790 --- /dev/null +++ b/locking/output/04-pessimistic-write.txt @@ -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 diff --git a/locking/output/05-no-wait.txt b/locking/output/05-no-wait.txt new file mode 100644 index 0000000..b2b2d37 --- /dev/null +++ b/locking/output/05-no-wait.txt @@ -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 diff --git a/locking/output/06-skip-locked.txt b/locking/output/06-skip-locked.txt new file mode 100644 index 0000000..5afba06 --- /dev/null +++ b/locking/output/06-skip-locked.txt @@ -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 diff --git a/locking/output/07-deadlock.txt b/locking/output/07-deadlock.txt new file mode 100644 index 0000000..4ecb0cf --- /dev/null +++ b/locking/output/07-deadlock.txt @@ -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 diff --git a/locking/pom.xml b/locking/pom.xml new file mode 100644 index 0000000..ed02661 --- /dev/null +++ b/locking/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + com.ankurm + locking + 1.0.0 + locking + Hibernate 7 optimistic vs pessimistic locking: lost updates, @Version, retries, PESSIMISTIC_WRITE, SKIP LOCKED and deadlocks, run with plain Hibernate (no Spring) + + + 25 + UTF-8 + 7.4.14.Final + + + + + org.hibernate.orm + hibernate-core + ${hibernate.version} + + + jakarta.persistence + jakarta.persistence-api + 3.2.0 + + + com.h2database + h2 + 2.4.240 + + + org.junit.jupiter + junit-jupiter + 6.0.3 + test + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + maven-surefire-plugin + 3.6.0 + + + + diff --git a/locking/scripts/run-all.sh b/locking/scripts/run-all.sh new file mode 100755 index 0000000..a026be8 --- /dev/null +++ b/locking/scripts/run-all.sh @@ -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 diff --git a/locking/src/main/java/com/ankurm/locking/Account.java b/locking/src/main/java/com/ankurm/locking/Account.java new file mode 100644 index 0000000..747fe3a --- /dev/null +++ b/locking/src/main/java/com/ankurm/locking/Account.java @@ -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; + } +} diff --git a/locking/src/main/java/com/ankurm/locking/Ticket.java b/locking/src/main/java/com/ankurm/locking/Ticket.java new file mode 100644 index 0000000..d2002e8 --- /dev/null +++ b/locking/src/main/java/com/ankurm/locking/Ticket.java @@ -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; + } +} diff --git a/locking/src/main/java/com/ankurm/locking/VersionedAccount.java b/locking/src/main/java/com/ankurm/locking/VersionedAccount.java new file mode 100644 index 0000000..f766c5f --- /dev/null +++ b/locking/src/main/java/com/ankurm/locking/VersionedAccount.java @@ -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; + } +} diff --git a/locking/src/test/java/com/ankurm/locking/Db.java b/locking/src/test/java/com/ankurm/locking/Db.java new file mode 100644 index 0000000..2d30e43 --- /dev/null +++ b/locking/src/test/java/com/ankurm/locking/Db.java @@ -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 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 jdbc(String query) { + List 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(); + } +} diff --git a/locking/src/test/java/com/ankurm/locking/OptimisticTest.java b/locking/src/test/java/com/ankurm/locking/OptimisticTest.java new file mode 100644 index 0000000..bb37f6c --- /dev/null +++ b/locking/src/test/java/com/ankurm/locking/OptimisticTest.java @@ -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 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> 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); + } + } +} diff --git a/locking/src/test/java/com/ankurm/locking/PessimisticTest.java b/locking/src/test/java/com/ankurm/locking/PessimisticTest.java new file mode 100644 index 0000000..80e09d5 --- /dev/null +++ b/locking/src/test/java/com/ankurm/locking/PessimisticTest.java @@ -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 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 claimedByA = new ArrayList<>(); + List 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 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 claim(org.hibernate.Session s, int n) { + List 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 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 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 transfer(Db db, boolean syncAfterFirstLock, long aFirst, long aSecond, long bFirst, long bSecond) { + CountDownLatch bothHoldFirst = new CountDownLatch(2); + ExecutorService pool = Executors.newFixedThreadPool(2); + List> 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 failures = new ArrayList<>(); + for (Future 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); + } + } +} diff --git a/locking/src/test/java/com/ankurm/locking/Transcript.java b/locking/src/test/java/com/ankurm/locking/Transcript.java new file mode 100644 index 0000000..20f383b --- /dev/null +++ b/locking/src/test/java/com/ankurm/locking/Transcript.java @@ -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 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); + } + } +}