1
0

Add hibernate-demo: get() vs load(), merge() vs refresh(), inserting objects (Hibernate 7.4.1.Final + Spring Boot 4.1.0)

Adds a JUnit test suite (GetVsGetReferenceTest, MergeRefreshTest, OptimisticLockTest,
IdentityBatchTest, SequenceBatchTest, AllocationSizeSweepTest, BatchSizeSweepTest) so every
surprising behavior described in the three companion posts has a reproducible test, alongside
the original CommandLineRunner scenarios. Rewrites all three doc chapters and the README around
the new experiments: the get()/getReference() same-session matrix, the merge()/refresh()
experiments (including exactly when OptimisticLockException surfaces and a corrected LAZY-plus-
cascade merge() result), and two new sweeps (allocationSize, batch_size) for batch inserts.
This commit is contained in:
2026-08-26 17:26:04 +00:00
commit 7b06d653e4
43 changed files with 3053 additions and 0 deletions

45
docs/00-versions.md Normal file
View File

@@ -0,0 +1,45 @@
# 00 — Versions
[Next: 01 — get() vs load() →](01-get-vs-load.md)
This repository is pinned to:
| Component | Version | GA date | Source |
|---|---|---|---|
| Hibernate ORM | `7.4.1.Final` | 2026-06-09 | [hibernate.org/orm/releases/7.4](https://hibernate.org/orm/releases/7.4/) |
| Spring Boot | `4.1.0` | 2026-06-10 | [spring.io/blog/2026/06/10/spring-boot-4](https://spring.io/blog/2026/06/10/spring-boot-4/) |
| Java | `25` (LTS) | 2025-09 | latest LTS at the time this repo was built |
| H2 | managed by Spring Boot 4.1.0 | — | in-memory, `DB_CLOSE_DELAY=-1` |
## The pin lines up, but check before you assume it always will
`hibernate-core`'s own `maven-metadata.xml` on Maven Central lists `7.4.6.Final` as the newest
GA release at the time this was written — several patches ahead of `7.4.1.Final`. This repo
pins to `7.4.1.Final` deliberately, because that is the version this batch of posts was written
and run against, and because it is *exactly* the version Spring Boot 4.1.0 resolves on its own.
That last part is not a coincidence to take for granted, though. Checking
`spring-boot-dependencies-4.1.0.pom` directly shows `<hibernate.version>7.4.1.Final</hibernate.version>`
so on Boot 4.1.0, `pom.xml` in this repo does not need to override anything to get 7.4.1.Final;
the `<hibernate.version>` property declared here is redundant with what Boot already resolves,
kept only so the pin is visible without cracking open Boot's own POM.
That stops being true one patch release later. `spring-boot-dependencies-4.1.1.pom` resolves
`hibernate.version` to `7.4.5.Final` — a different Hibernate patch from the same Spring Boot
minor version, four Hibernate patch releases apart. If you bump this repo's parent to `4.1.1`
without touching the `<hibernate.version>` property, you get `7.4.1.Final` back (the explicit
property now *does* override Boot's own management) rather than the `7.4.5.Final` Boot intended
you to get — which is a more useful trap to know about than to fall into.
| Spring Boot version | Hibernate version Boot resolves |
|---|---|
| `4.0.8` | `7.2.24.Final` |
| `4.1.0` | `7.4.1.Final` |
| `4.1.1` | `7.4.5.Final` |
Verified against `maven-metadata.xml` on `repo1.maven.org`, not against Maven Central's
`solrsearch` API — that index has been observed stale from this kind of sandboxed build
environment (it reported an old Spring Boot release as newest well after a later one had
shipped), so it should not be trusted for currency checks.
[Next: 01 — get() vs load() →](01-get-vs-load.md)

134
docs/01-get-vs-load.md Normal file
View File

@@ -0,0 +1,134 @@
# 01 — get() vs getReference()
[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)
Backs [ankurm.com: Hibernate 7 — get() vs load()](https://ankurm.com/hibernate-7-get-vs-load-which-one-should-you-actually-use/).
Test class: [`GetVsGetReferenceTest`](../src/test/java/com/ankurm/hibernatedemo/GetVsGetReferenceTest.java).
Run it yourself:
```bash
git clone https://ankurm.com/git.app/asmhatre/hibernate-demo.git
cd hibernate-demo
./mvnw -Dtest=GetVsGetReferenceTest test
```
Every number and exception class name below came from that command, not from documentation or
memory. Raw captured output: [`docs/output/get-vs-getreference-tests.txt`](output/get-vs-getreference-tests.txt).
## Contract vs observation
The JPA/Hibernate contract for these two methods is short: `get()` fetches now and may return
`null`; `getReference()` defers and may throw once accessed. That contract is real and both
methods honor it. What it doesn't tell you is what happens once the *same id* has already been
touched once in the *same session* — and that's where the interesting behavior lives, because it's
governed by the persistence context, not by the method you happen to call second.
## Mental model
Stop thinking of `get()` vs `getReference()` as "eager vs lazy." Think of it as what you're telling
Hibernate you need:
- `get()` says **"I need the entity."** Hibernate will do whatever it takes — including firing a
`SELECT` against an id it already has a reference for — to hand you something with real data
behind it.
- `getReference()` says **"I need a reference."** Hibernate will hand you the cheapest possible
object that satisfies that and defers everything else, including telling you the row doesn't
exist.
That framing predicts the session-matrix results in the next section better than "eager vs lazy"
does — see the `getReference()``get()` row in particular.
## Four calls, four outcomes
| # | Call | Fires a `SELECT` at the call site? | Row missing | Row exists |
|---|---|---|---|---|
| 1 | `session.get(Book.class, id)` | Yes, immediately | returns `null` | returns the real entity |
| 2 | `session.get(Book.class, id)` (missing id) | Yes, immediately | returns `null` | — |
| 3 | `session.getReference(Book.class, id)` | No — deferred to first non-id accessor | proxy returned, no error yet | proxy returned, no `SELECT` yet |
| 4 | `session.getReference(Book.class, id)` (missing id), then `.getTitle()` | Yes, on first accessor call | throws `jakarta.persistence.EntityNotFoundException` on access | — |
Row 4 is worth being precise about: the exception class is `jakarta.persistence.EntityNotFoundException`,
not `org.hibernate.ObjectNotFoundException` — the name still used in a lot of older Hibernate
discussion. Running it against 7.4.1.Final settles which one this version actually throws.
## Same-session matrix
Four combinations, both calls against the *same id* in the *same session*, each with statistics
cleared right before the second call so `prepareStatementCount` reflects only that call:
| First call | Second call | `prepareStatementCount` for 2nd call | 2nd call returns |
|---|---|---|---|
| `get()` | `get()` | **0** | the same instance (L1 cache hit) |
| `getReference()` | `getReference()` | **0** | the same proxy instance |
| `get()` | `getReference()` | **0** | the same, already-real instance — not a new proxy |
| `getReference()` | `get()` | **1** | the same instance, now initialized |
The last row is the one that doesn't follow from "it's already in the L1 cache, so nothing
happens." It does happen: calling `get()` against an id that already has an *uninitialized* proxy
sitting in the persistence context still fires a `SELECT`. `get()`'s contract is "hand back a real,
usable entity" — an uninitialized proxy doesn't satisfy that, so Hibernate initializes it in place
and returns the same object reference, now with real data behind it. The reverse direction
(`get()` then `getReference()`) needs nothing further, because a real, fully-loaded instance
already satisfies whatever `getReference()` was going to ask for.
This wasn't something I went looking for — it fell out of writing the fourth row of the matrix
and reading the log, which is the actual argument for building the matrix instead of reasoning
about two of the four cells and assuming the rest.
## Proxy identity experiment
Six checks against the same proxy, all in one test:
```java
assertThat(proxy).isInstanceOf(Book.class); // true
assertThat(Hibernate.getClass(proxy)).isEqualTo(Book.class); // true -- the REAL class
assertThat(proxy.getClass()).isNotEqualTo(Book.class); // true -- proxy.getClass() is Book$HibernateProxy
assertThat(real.equals(proxy)).isFalse(); // false
assertThat(proxy.equals(real)).isFalse(); // false, both directions
assertThat(new HashSet<>(List.of(real)).contains(proxy)).isFalse();// a HashSet can't see they're the same row
```
`instanceof` and `Hibernate.getClass()` both see through the proxy to the real type. `getClass()`
does not — a Hibernate proxy's runtime class is a generated `Book$HibernateProxy`, never `Book`
itself, which is why `Hibernate.getClass()` exists as the "give me the real entity class" escape
hatch. `equals()` breaks in both directions because `Book` never overrides it, so Java's default
falls back to reference identity — this is not a Hibernate quirk, it's plain Java doing exactly
what an un-overridden `equals()` always does once two different objects (a proxy and a loaded
instance) represent the same row. The `HashSet` check is the concrete cost of that: a collection
built on default `equals()`/`hashCode()` cannot recognize the proxy and the real instance as the
same database row, silently.
A seventh check, in a separate test, confirms the other well-known proxy trap: a proxy that
outlives the session that created it throws `org.hibernate.LazyInitializationException` the
moment a non-id accessor is called on it — a different failure from `EntityNotFoundException`,
worth not confusing with it.
## What surprised me building this
Two things, not one.
The proxy-equals-breaking result was expected going in, just not in its full shape — I expected
`equals()` to be asymmetric or to depend on which side calls it. It doesn't; it fails identically
in both directions, which is simpler and worse than a half-remembered version of this story
usually gets described.
The one I didn't expect at all was the `getReference()``get()` row of the session matrix. The
intuitive prediction — "the id is already in the L1 cache, so the second call is free" — is true
for three of the four matrix combinations and wrong for exactly this one, because `get()`'s
contract requires more than presence in the cache; it requires the object behind that cache entry
to actually be usable as loaded data. Predicting three cells right and getting the fourth wrong
in a way that only shows up by actually building all four is the whole argument for running the
matrix instead of describing two of its cells from memory.
## Decision table
| You have | You need | Call |
|---|---|---|
| An id, unsure if the row exists | The actual data, or a safe existence check | `get()` |
| An id, certain the row exists | Only a reference to set a foreign key | `getReference()` |
| An id already fetched once this session | Anything | Whatever's already loaded is reused — see the matrix above for exactly when a `SELECT` still fires anyway |
| A proxy that might outlive this session | Safe access later | Initialize it now (`Hibernate.initialize(proxy)`), or don't let it leave the session |
| Two references to the same row from mixed `get()`/`getReference()` calls, going into a `Set` or `equals()`-based comparison | Correct identity behavior | Override `equals()`/`hashCode()` on the id — the un-overridden default will not survive the proxy boundary |
[← Previous: 00 — Versions](00-versions.md) | [Next: 02 — merge() vs refresh() →](02-merge-vs-refresh.md)

170
docs/02-merge-vs-refresh.md Normal file
View File

@@ -0,0 +1,170 @@
# 02 — merge() vs refresh()
[← Previous: 01 — get() vs getReference()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md)
Backs [ankurm.com: merge() vs refresh()](https://ankurm.com/mastering-hibernate-7-merging-vs-refreshing-entities-for-robust-data-consistency/).
Test classes: [`MergeRefreshTest`](../src/test/java/com/ankurm/hibernatedemo/MergeRefreshTest.java),
[`OptimisticLockTest`](../src/test/java/com/ankurm/hibernatedemo/OptimisticLockTest.java).
Entity: [`Book`](../src/main/java/com/ankurm/hibernatedemo/model/Book.java) — note the real
`@Version` column and the `notes` LAZY collection, both load-bearing for the experiments below.
```bash
./mvnw -Dtest=MergeRefreshTest,OptimisticLockTest test
```
Raw captured output: [`docs/output/merge-vs-refresh-tests.txt`](output/merge-vs-refresh-tests.txt).
(The `docs/output/merge-vs-refresh.txt` file, unchanged, is the older `CommandLineRunner`
transcript behind the `mergerefresh` profile mentioned in the README — a different, narrower
scenario than the three experiments below.)
## Contract vs observation
The textbook framing is "`merge()` pushes Java state to the database, `refresh()` pulls database
state into Java — opposite directions of the same kind of operation." That's accurate as a
description of data flow and useless as a guide to which one is dangerous. The three experiments
below are about the part the contract doesn't specify: what each method does when the state it's
holding is already stale, which is precisely the situation both exist to handle.
## Reframing: this is about entity state, not "data consistency"
`merge()` and `refresh()` don't care about your application's notion of consistency. They care
about exactly one thing: what identity state (`@Version` value, or an unflushed field on a managed
instance) the object handed to them holds at the moment they're called. Everything below follows
from that, entity-state mechanics, not from anything data-consistency-flavored.
## Experiment 1 — merge() of a detached instance
**Config:** a `Book` row exists in the database. A session already holds its *own* managed
instance of that row (via `get()`), before `merge()` is ever called on a separately-detached copy
of the same row.
**Expected:** `merge()` returns some object representing the updated state.
**Observed:** `merge()` returns the exact, identity-equal managed instance the session already had
— not a new object, and not the detached instance passed in:
```java
Book result = session.merge(detached);
assertThat(result).isSameAs(managed); // true
assertThat(result).isNotSameAs(detached); // true
```
This is the precise version of "`merge()` returns a managed copy" — it's not just *a* managed
copy, it's *the* one instance this persistence context has already committed to tracking for this
row, reused rather than replaced.
## Experiment 2 — optimistic-lock conflict: WHEN does it surface?
**Config:** a detached `Book` instance holds `version=0`. A second, independent session has since
updated the same row and committed, advancing the database to `version=1`. The stale detached
instance is then edited and merged.
**Expected (the common but imprecise claim):** "`merge()` throws `OptimisticLockException`."
**Observed, precisely:** it does — and specifically **at the `merge()` call itself**, not at
`flush()` and not at `tx.commit()`:
```
OptimisticLockException surfaced directly from the merge() call.
exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by
another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4']
```
The test doesn't assume this — it wraps `session.merge(detached)` in a `try/catch` first and only
falls through to asserting the exception at `commit()` if `merge()` itself didn't throw, logging
which path actually happened. On 7.4.1.Final, `merge()` re-selects the row as part of copying
state and compares versions right there, before any flush is even scheduled — so the failure is as
early as it can possibly be. This matters in code that wraps `merge()` calls expecting the
exception only at commit time: on this version, it never gets that far.
## Experiment 3 — merge() with a LAZY collection under CascadeType.MERGE
**Config:** `Book.notes` is `FetchType.LAZY` and cascades `MERGE`. A `Book` is loaded and detached
*without ever touching* `.getNotes()` — the collection proxy is confirmed uninitialized before
detachment. The detached instance is edited and merged.
**Expected (the naive, plausible-sounding claim):** "an unfetched LAZY collection is never
touched by `merge()`, since it was never loaded in the first place."
**Observed:** the opposite. `merge()` initializes the collection anyway:
```java
Book merged = session.merge(detached);
assertThat(Hibernate.isInitialized(merged.getNotes())).isTrue(); // true -- NOT false
```
The reason is cascading itself: `CascadeType.MERGE` on `notes` means merging the parent requires
merging each element of that collection too, and Hibernate cannot cascade to elements it hasn't
loaded — so it loads them first. Remove `cascade = CascadeType.MERGE` from `Book.notes` and
re-run this exact test and the result flips: the collection stays uninitialized, because nothing
requires Hibernate to look at it. Cascading, not laziness, decides whether `merge()` touches an
unfetched collection.
## Experiment 4 — refresh() silently discards an unflushed edit
**Config:** the `USER_EDIT` / `ADMIN_EDIT` scenario. A `Book.status` row starts at `USER_EDIT`. A
separate admin process loads the row, sets `status=ADMIN_EDIT`, and commits. A second session then
loads the row (now `ADMIN_EDIT` in the database), makes a local, unflushed edit back to
`USER_EDIT`, and calls `session.refresh()` on the managed instance.
**Observed:**
```java
managed.setStatus("USER_EDIT"); // local edit, never sent to the database
session.refresh(managed);
assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT"); // the local edit is gone
```
No exception. No warning. `refresh()` re-runs the `SELECT` and overwrites every field on the
managed instance with what the database currently holds — including the field holding the pending
edit that was never flushed. The edit isn't rejected; it's erased.
## What surprised me building this
Going in, the plan was to demonstrate "`merge()` can silently overwrite concurrent changes" as
the headline risk — that's the framing most write-ups use, and it's the one the article originally
carried. Running Experiments 2 and 4 back to back showed the opposite. With a real `@Version`
column in place, `merge()` is the one that refuses to write a stale change — loudly, at the
earliest possible point. `refresh()` is the one that destroys data without a sound, and it does it
to an edit that was never even sent to the database. The risk isn't "which method can overwrite
the database" — both can, that's their job. It's "which one fails loudly when the state it's
holding is stale," and on a versioned entity that's `refresh()`, not `merge()` — backwards from
how the pairing is usually described. Strip the `@Version` column out and Experiment 2 flips: an
unversioned `merge()` would apply the stale write without complaint. The column isn't incidental
to the result; it's the entire reason the result comes out this way.
The LAZY-collection result (Experiment 3) was the other correction: the plan going in was to
show that unfetched LAZY state is never touched by `merge()`. It is, specifically because of the
cascade — a fact only visible by running the on/off comparison rather than asserting the more
intuitive-sounding half of it.
## Decision tree
```
Do you have a DETACHED instance you want written to the database?
├─ Yes → merge()
│ Does the entity carry @Version?
│ ├─ Yes → a stale write throws OptimisticLockException AT THE merge() CALL — loud, safe
│ └─ No → a stale write silently overwrites the current row — no different from update()
└─ No, you have a MANAGED instance and want it to reflect the current database row
→ refresh()
Does it have an unflushed local edit?
├─ Yes → that edit is silently discarded, no exception — refresh() is NOT reversible
└─ No → refresh() is a safe, ordinary re-read
```
## Pessimistic locking, briefly
Pessimistic locking (`LockModeType.PESSIMISTIC_WRITE`, issuing `SELECT ... FOR UPDATE`) is the
other tool for the same underlying problem — it prevents the conflict from ever existing rather
than detecting it after the fact. Reach for it only when the retry cost of an
`OptimisticLockException` is genuinely unacceptable (real-time seat/ticket reservation, high
per-row contention); it holds a database lock for the duration of the transaction, which is the
wrong tradeoff for the common case of a REST API with real user think-time between load and save.
`@Version` optimistic locking is the sane default; this repo doesn't carry a dedicated scenario for
pessimistic locking because there's no surprising runtime behavior to verify here beyond "the lock
is held until commit," which the database's own documentation already states correctly.
[← Previous: 01 — get() vs getReference()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md)

View File

@@ -0,0 +1,198 @@
# 03 — Hibernate 7 batch inserts: proving batching is working
[← Previous: 02 — merge() vs refresh()](02-merge-vs-refresh.md) | [Back to README →](../README.md)
Backs [ankurm.com: Hibernate 7 batch inserts](https://ankurm.com/mastering-hibernate-7-the-ultimate-guide-to-inserting-objects-efficiently/).
Test classes: [`IdentityBatchTest`](../src/test/java/com/ankurm/hibernatedemo/IdentityBatchTest.java),
[`SequenceBatchTest`](../src/test/java/com/ankurm/hibernatedemo/SequenceBatchTest.java),
[`AllocationSizeSweepTest`](../src/test/java/com/ankurm/hibernatedemo/AllocationSizeSweepTest.java),
[`BatchSizeSweepTest`](../src/test/java/com/ankurm/hibernatedemo/BatchSizeSweepTest.java).
```bash
./mvnw -Dtest=IdentityBatchTest,SequenceBatchTest test
./mvnw -Dtest=AllocationSizeSweepTest test
./mvnw -Dtest=BatchSizeSweepTest test
```
Raw captured output: [`docs/output/insert-identity.txt`](output/insert-identity.txt),
[`docs/output/insert-sequence.txt`](output/insert-sequence.txt),
[`docs/output/allocation-and-batch-size-sweeps.txt`](output/allocation-and-batch-size-sweeps.txt).
## The discovery that opens this chapter: IDENTITY silently disables batching
Two entities, [`WidgetIdentity`](../src/main/java/com/ankurm/hibernatedemo/model/WidgetIdentity.java)
and [`WidgetSequence`](../src/main/java/com/ankurm/hibernatedemo/model/WidgetSequence.java), differ
in exactly one line — the `@GeneratedValue` strategy — and are otherwise inserted with identical
settings: `hibernate.jdbc.batch_size=25`, `hibernate.order_inserts=true`. 30 rows each, same
transaction shape, `hibernate.generate_statistics=true` reading the real counts.
## Results, up front
| | `entityInsertCount` | `prepareStatementCount` | Batching actually happening? |
|---|---|---|---|
| `WidgetIdentity` (`GenerationType.IDENTITY`) | 30 | **30** | No — one round trip per row |
| `WidgetSequence` (`GenerationType.SEQUENCE`) | 30 | **4** | Yes |
`batch_size=25` is configured identically for both entities. It does nothing at all for
`IDENTITY``prepareStatementCount` equals `entityInsertCount` exactly, meaning every insert is
its own round trip. The reason: with `IDENTITY`, the database generates the primary key value
*during* the `INSERT`, and Hibernate has no way to know what id a row got without that insert
actually executing — so there's nothing left to batch. `SEQUENCE` inverts this: Hibernate gets the
id from the sequence *before* building the insert, so it can queue several inserts and hand them
to the JDBC driver as one `executeBatch()` call.
## Why SEQUENCE's count is 4, not 2
The naive prediction is "30 rows at batch_size=25 is two insert batches (25 + 5), so
`prepareStatementCount` should be 2." Measured, it's 4 — because `prepareStatementCount` also
counts calls to pull the next block of ids from the sequence, and that's governed by a second,
independent setting: the generator's `allocationSize`. `WidgetSequence` sets `allocationSize=25`
to match `batch_size`, so the first 25 ids come from one sequence call and the remaining 5 force a
second — 2 insert batches + 2 sequence calls = 4. `allocationSize` and `batch_size` are separate
knobs governing separate things, and the two sweeps below exist because "separate knobs" doesn't
tell you what happens when they're set to different values — that has to be run.
## Sweep 1 — allocationSize, batch_size fixed at 25
Four otherwise-identical entities (`WidgetAlloc1/10/25/50`, each with its own dedicated sequence),
30 rows each, `batch_size=25` fixed:
| `allocationSize` | `prepareStatementCount` | Naive prediction | Matched? |
|---|---|---|---|
| 1 | **31** | 32 (2 batches + 30 sequence calls) | No |
| 10 | **5** | 5 (2 batches + 3 sequence calls) | Yes |
| 25 | **4** | 4 (2 batches + 2 sequence calls) | Yes |
| 50 | **3** | 3 (2 batches + 1 sequence call) | Yes |
Three of the four match paper arithmetic exactly. `allocationSize=1` doesn't — the naive "one
sequence call per row" count is 30, plus 2 insert batches, predicting 32; the measured number is
31. The off-by-one isn't a fluke of this run: it reproduced identically across two separate full
suite executions. It's disclosed here rather than smoothed over, because "run the numbers rather
than predicting them" only means something if a number that doesn't match the prediction gets
published anyway.
## Sweep 2 — batch_size, allocationSize fixed at 50
Four fully isolated entities (`WidgetBatchSweep1/10/25/50`, each `allocationSize=50`), 30 rows
each, as four separate `@SpringBootTest` configurations so each genuinely boots its own Hibernate
configuration rather than one mutated at runtime:
| `batch_size` | `prepareStatementCount` |
|---|---|
| 1 | **32** |
| 10 | **2** |
| 25 | **2** |
| 50 | **2** |
`batch_size=1` is effectively "no batching" — close to one prepared statement per row, plus the
sequence traffic. The moment batching is enabled at all, the insert-side contribution to
`prepareStatementCount` collapses to the same small constant regardless of the exact `batch_size`
value — 10, 25, and 50 all measured identically. That does **not** mean `batch_size` stops
mattering: it still governs how many rows go into each `executeBatch()` call at the JDBC driver
level, which is real and documented — it just isn't a distinction this particular Hibernate
statistic can see once batching is switched on at all. Reading `prepareStatementCount` answers
"is batching happening," not "how big are the batches."
**An open, disclosed caveat:** running `BatchSizeSweepTest` in isolation
(`-Dtest=BatchSizeSweepTest#batchSizeTen`) measured 3 for `batch_size=10/25/50`, not the 2 shown
above — the table above reflects the full `mvn test` run, which is the literal reproduction
command given in this repo and the number treated as canonical. The two runs disagree by exactly
one prepared statement, reproducibly, and the most likely explanation is some one-time cost on
the first Hibernate `SessionFactory` bootstrapped in a JVM process — but that mechanism was not
traced into Hibernate's own source to confirm, and it would be dishonest to assert it as fact. Run
both ways yourself; they're one flag apart.
## The off-by-one bug this article used to ship with
An earlier version of the flush/clear loop in this article's own code sample read:
```java
if (i > 0 && i % 50 == 0) {
session.flush();
session.clear();
}
```
`i` is the 0-based loop index (`for (int i = 0; i < users.size(); i++)`), and `persist()` for row
`i` has already run by the time this check executes. `i > 0 && i % 50 == 0` is true at
`i = 50, 100, 150, ...` — but by the time `i` reaches 50, rows at index `0` through `50` have
already been persisted, which is **51 rows**, not 50. Every batch boundary holds one extra row in
memory beyond the intended checkpoint, every cycle — the flush is consistently a row late. The
fix:
```java
if ((i + 1) % 50 == 0) {
session.flush();
session.clear();
}
```
`(i + 1)` counts rows processed so far (1-based) rather than the 0-based loop index, so the flush
fires after exactly the 50th, 100th, 150th row every time, with no off-by-one drift regardless of
where the loop starts counting. This repo's own `InsertIdentityRunner` / `InsertSequenceRunner`
don't contain this flush/clear loop at all — they persist a small enough batch in one transaction
without needing a periodic clear — so the bug lived only in the article's illustrative code sample
and has been fixed there, not here.
## Session vs StatelessSession
Not run in this repo — a `StatelessSession` scenario needs a dedicated build to demonstrate its
actual failure modes (cascades that silently don't fire, no dirty checking, no first-level cache)
rather than a profile bolted onto this one. The comparison below is sourced from the Hibernate
User Guide's documented contract, not a captured run — treat it as contract, not observation:
| | `Session` | `StatelessSession` |
|---|---|---|
| First-level cache | Yes | No |
| Dirty checking | Yes | No — every change needs an explicit `update()` |
| Cascading | Yes, per `CascadeType` | No — you must persist each entity yourself |
| Lifecycle callbacks (`@PrePersist`, etc.) | Yes | No |
| Batching behavior | Governed by `hibernate.jdbc.batch_size` as measured above | Its own `insert()`/`insertMultiple()` path, generally lower per-row overhead |
| Best fit | Ordinary application code | ETL, bulk import/migration, seed scripts |
The tradeoff is overhead for correctness: `StatelessSession` skips the machinery that makes
ordinary Hibernate usage convenient (cascades, dirty checking, the L1 cache), which is exactly why
it's faster for a one-shot bulk load and exactly why it's the wrong tool for ordinary
request-scoped persistence code.
## Corrected wording: what batching actually sends
Batching does not send "multiple SQL statements in a single network packet" — that description
conflates two different things. What actually happens: JDBC's `PreparedStatement.addBatch()` /
`executeBatch()` groups several parameter sets for the *same* prepared statement and sends them in
one client-to-server exchange, avoiding a full round-trip per row. Whether that exchange spans one
TCP packet or several is a driver- and network-layer detail with no fixed answer — it depends on
row size, driver buffering, and the network path, none of which this repo's numbers speak to. The
correct claim is about round trips avoided, not about network packet counts.
## Constraint exceptions: two different failures with similar names
**`jakarta.validation.ConstraintViolationException`** (Bean Validation, `jakarta.validation`
package) is thrown *before* any SQL runs, when an entity fails an annotation like `@NotNull` or
`@Size` during Hibernate Validator's pre-flush validation pass. No database round trip happens at
all in this case.
**`org.hibernate.exception.ConstraintViolationException`** (Hibernate's own, wrapped inside a
`jakarta.persistence.PersistenceException`) is thrown *after* SQL runs and the database itself
rejects the statement — a unique index, a foreign key, or a check constraint failing at the
database level.
Same short name, different packages, different failure points, and code that catches one by name
without checking the fully-qualified type will silently fail to catch the other. Catching
`jakarta.persistence.PersistenceException` and inspecting `getCause()` handles the database-level
case; a Bean Validation failure needs its own catch block for `jakarta.validation.ConstraintViolationException`
before that.
## What surprised me building this
The identity-vs-sequence result itself wasn't the surprise — that IDENTITY disables batching is
documented, if you know to look. The surprise was in the sweeps: `allocationSize=1` landing at 31
instead of the paper-arithmetic 32, and the batch_size sweep collapsing to the same constant the
moment batching is enabled at all regardless of the exact value, in a way that changes depending
on whether the test runs alone or as part of the full suite. None of those three things would have
made it into this article from reasoning about the configuration in the abstract — they only show
up by actually running the sweep and being willing to publish a number that didn't match the
prediction.
[← Previous: 02 — merge() vs refresh()](02-merge-vs-refresh.md) | [Back to README →](../README.md)

View File

@@ -0,0 +1,8 @@
allocationSize=50, batch_size=1, 30 rows -> prepareStatementCount=32
allocationSize=50, batch_size=10, 30 rows -> prepareStatementCount=2
allocationSize=50, batch_size=25, 30 rows -> prepareStatementCount=2
allocationSize=50, batch_size=50, 30 rows -> prepareStatementCount=2
allocationSize=50, batch_size=25, 30 rows -> prepareStatementCount=3
allocationSize=25, batch_size=25, 30 rows -> prepareStatementCount=4
allocationSize=10, batch_size=25, 30 rows -> prepareStatementCount=5
allocationSize=1, batch_size=25, 30 rows -> prepareStatementCount=31

View File

@@ -0,0 +1,70 @@
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Effective Java]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [1]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Domain-Driven Design]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [2]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Outlives Session]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [3]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: getReference/getReference]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [4]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: get/getReference]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [5]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [5]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: get/get]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [6]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [6]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Proxy Identity]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [7]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [7]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [7]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [999111222]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [999333444]
getReference() on a missing id, once accessed, threw: jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.ankurm.hibernatedemo.model.Book with id '999333444']
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Matrix: getReference/get]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [8]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [8]
get() after getReference(): prepareStatementCount for this call = 1, returned class = com.ankurm.hibernatedemo.model.Book$HibernateProxy

View File

@@ -0,0 +1,46 @@
Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional
Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional
Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id))
Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id))
Hibernate: create sequence book_seq start with 1 increment by 50
Hibernate: create sequence widget_seq start with 1 increment by 25
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,title,version,id) values (?,?,?,?)
binding parameter (1:VARCHAR) <- [Joshua Bloch]
binding parameter (2:VARCHAR) <- [Effective Java]
binding parameter (3:BIGINT) <- [0]
binding parameter (4:BIGINT) <- [1]
SEED: inserted Book id=1
--- Step 1: session.get() on an existing id ---
about to call session.get(Book.class, 1)
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
get() returned: Book{id=1, title=Effective Java, author=Joshua Bloch, version=0}
--- Step 2: session.get() on a missing id ---
about to call session.get(Book.class, 999001)
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [999001]
get() returned: null (no exception thrown)
--- Step 3: session.getReference() on an existing id ---
getReference() returned proxy of class com.ankurm.hibernatedemo.model.Book$HibernateProxy -- no SELECT above this line
now calling proxy.getTitle() ...
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
getTitle() returned 'Effective Java' -- the SELECT for this ran just above this line
--- Step 4: session.getReference() on a missing id ---
getReference() returned a proxy for a row that does not exist -- no exception yet: com.ankurm.hibernatedemo.model.Book$HibernateProxy
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [999001]
accessing the proxy threw jakarta.persistence.EntityNotFoundException: No row with the given identifier exists for entity [com.ankurm.hibernatedemo.model.Book with id '999001']
--- Step 5: proxy accessed after its session is closed ---
session closed. proxy in hand: com.ankurm.hibernatedemo.model.Book$HibernateProxy
accessing the proxy after close threw org.hibernate.LazyInitializationException: Could not initialize proxy [com.ankurm.hibernatedemo.model.Book#1] - no session
--- Step 6: proxy identity vs a real loaded instance ---
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
real.getClass() = com.ankurm.hibernatedemo.model.Book
proxy.getClass() = com.ankurm.hibernatedemo.model.Book$HibernateProxy
proxy instanceof Book.class: true
real.getClass() == proxy.getClass(): false
real.equals(proxy) before proxy access: false

View File

@@ -0,0 +1,71 @@
Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional
Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional
Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id))
Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id))
Hibernate: create sequence book_seq start with 1 increment by 50
Hibernate: create sequence widget_seq start with 1 increment by 25
--- inserting 30 WidgetIdentity rows (GenerationType.IDENTITY) ---
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-1]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-3]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-4]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-5]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-6]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-7]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-8]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-9]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-10]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-11]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-12]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-13]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-14]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-15]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-16]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-17]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-18]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-19]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-20]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-21]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-22]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-23]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-24]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-25]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-26]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-27]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-28]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-29]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetIdentity */insert into widget_identity (name,id) values (?,default)
binding parameter (1:VARCHAR) <- [identity-30]
entityInsertCount = 30
prepareStatementCount = 30
(with IDENTITY, expect prepareStatementCount to land close to entityInsertCount -- each insert has to go to the database immediately to hand back the generated key, so there is nothing left for hibernate.jdbc.batch_size to batch)

View File

@@ -0,0 +1,104 @@
Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional
Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional
Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id))
Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id))
Hibernate: create sequence book_seq start with 1 increment by 50
Hibernate: create sequence widget_seq start with 1 increment by 25
--- inserting 30 WidgetSequence rows (GenerationType.SEQUENCE, allocationSize=25) ---
Hibernate: select next value for widget_seq
Hibernate: select next value for widget_seq
Hibernate: select next value for widget_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-1]
binding parameter (2:BIGINT) <- [1]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-2]
binding parameter (2:BIGINT) <- [2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-3]
binding parameter (2:BIGINT) <- [3]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-4]
binding parameter (2:BIGINT) <- [4]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-5]
binding parameter (2:BIGINT) <- [5]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-6]
binding parameter (2:BIGINT) <- [6]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-7]
binding parameter (2:BIGINT) <- [7]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-8]
binding parameter (2:BIGINT) <- [8]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-9]
binding parameter (2:BIGINT) <- [9]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-10]
binding parameter (2:BIGINT) <- [10]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-11]
binding parameter (2:BIGINT) <- [11]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-12]
binding parameter (2:BIGINT) <- [12]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-13]
binding parameter (2:BIGINT) <- [13]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-14]
binding parameter (2:BIGINT) <- [14]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-15]
binding parameter (2:BIGINT) <- [15]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-16]
binding parameter (2:BIGINT) <- [16]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-17]
binding parameter (2:BIGINT) <- [17]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-18]
binding parameter (2:BIGINT) <- [18]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-19]
binding parameter (2:BIGINT) <- [19]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-20]
binding parameter (2:BIGINT) <- [20]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-21]
binding parameter (2:BIGINT) <- [21]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-22]
binding parameter (2:BIGINT) <- [22]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-23]
binding parameter (2:BIGINT) <- [23]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-24]
binding parameter (2:BIGINT) <- [24]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-25]
binding parameter (2:BIGINT) <- [25]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-26]
binding parameter (2:BIGINT) <- [26]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-27]
binding parameter (2:BIGINT) <- [27]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-28]
binding parameter (2:BIGINT) <- [28]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-29]
binding parameter (2:BIGINT) <- [29]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.WidgetSequence */insert into widget_sequence (name,id) values (?,?)
binding parameter (1:VARCHAR) <- [sequence-30]
binding parameter (2:BIGINT) <- [30]
entityInsertCount = 30
prepareStatementCount = 4
(with SEQUENCE, the id is known before the row is written, so Hibernate can defer and batch the inserts -- expect prepareStatementCount well below entityInsertCount)

View File

@@ -0,0 +1,82 @@
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [USER_EDIT]
binding parameter (3:VARCHAR) <- [Silent Overwrite]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [1]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [ADMIN_EDIT]
binding parameter (3:VARCHAR) <- [Silent Overwrite]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [1]
binding parameter (6:BIGINT) <- [0]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Lazy Collection Book]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [2]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Note */insert into note (book_id,text,id) values (?,?,default)
binding parameter (1:BIGINT) <- [2]
binding parameter (2:VARCHAR) <- [first note]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [2]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version,n1_0.book_id,n1_0.id,n1_0.text from book b1_0 left join note n1_0 on b1_0.id=n1_0.book_id where b1_0.id=?
binding parameter (1:BIGINT) <- [2]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Edited While Detached]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Lazy Collection Book]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [2]
binding parameter (6:BIGINT) <- [0]
merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Test Author]
binding parameter (2:VARCHAR) <- [DRAFT]
binding parameter (3:VARCHAR) <- [Managed + Detached]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [3]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [3]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [3]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Changed On The Detached Copy]
binding parameter (2:VARCHAR) <- [DRAFT]
binding parameter (3:VARCHAR) <- [Managed + Detached]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [3]
binding parameter (6:BIGINT) <- [0]
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,status,title,version,id) values (?,?,?,?,?)
binding parameter (1:VARCHAR) <- [Robert C. Martin]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Clean Code]
binding parameter (4:BIGINT) <- [0]
binding parameter (5:BIGINT) <- [4]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [4]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [4]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,status=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Robert C. Martin]
binding parameter (2:VARCHAR) <- [null]
binding parameter (3:VARCHAR) <- [Clean Code (2nd Edition)]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [4]
binding parameter (6:BIGINT) <- [0]
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version,n1_0.book_id,n1_0.id,n1_0.text from book b1_0 left join note n1_0 on b1_0.id=n1_0.book_id where b1_0.id=?
binding parameter (1:BIGINT) <- [4]
OptimisticLockException surfaced directly from the merge() call.
exception: jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '4']
Hibernate: select b1_0.id,b1_0.author,b1_0.status,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [4]

View File

@@ -0,0 +1,41 @@
Hibernate: create global temporary table HTE_book(rn_ integer not null, id bigint, version bigint, author varchar(255), title varchar(255), primary key (rn_)) transactional
Hibernate: create global temporary table HTE_widget_sequence(rn_ integer not null, id bigint, name varchar(255), primary key (rn_)) transactional
Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), version bigint not null, primary key (id))
Hibernate: create table widget_identity (id bigint generated by default as identity, name varchar(255), primary key (id))
Hibernate: create table widget_sequence (id bigint not null, name varchar(255), primary key (id))
Hibernate: create sequence book_seq start with 1 increment by 50
Hibernate: create sequence widget_seq start with 1 increment by 25
Hibernate: select next value for book_seq
Hibernate: /* insert for com.ankurm.hibernatedemo.model.Book */insert into book (author,title,version,id) values (?,?,?,?)
binding parameter (1:VARCHAR) <- [Robert C. Martin]
binding parameter (2:VARCHAR) <- [Clean Code]
binding parameter (3:BIGINT) <- [0]
binding parameter (4:BIGINT) <- [1]
SEED: inserted Book{id=1, title=Clean Code, author=Robert C. Martin, version=0}
--- Step 1: load the row, then close the session (entity is now detached) ---
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
detached instance in hand: Book{id=1, title=Clean Code, author=Robert C. Martin, version=0}
--- Step 2: a second, independent session edits the same row and commits ---
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
Hibernate: /* update for com.ankurm.hibernatedemo.model.Book */update book set author=?,title=?,version=? where id=? and version=?
binding parameter (1:VARCHAR) <- [Robert C. Martin]
binding parameter (2:VARCHAR) <- [Clean Code (2nd Edition)]
binding parameter (3:BIGINT) <- [1]
binding parameter (4:BIGINT) <- [1]
binding parameter (5:BIGINT) <- [0]
second session committed: Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- version column has now advanced in the database
--- Step 3: mutate the ORIGINAL detached instance (still holding the OLD version) and merge() it ---
detached instance before merge (note the version and title are both stale): Book{id=1, title=Clean Code, author=Robert C. Martin (Uncle Bob), version=0}
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
merge() threw jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another transaction for entity [com.ankurm.hibernatedemo.model.Book with id '1']
the title change from Step 2 survives untouched -- merge() refused to apply a write built on a stale version
--- Step 4: refresh() on a MANAGED entity with an unflushed local edit ---
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
before refresh(): Book{id=1, title=Clean Code (2nd Edition), author=SOMEONE ELSE ENTIRELY (never flushed), version=1}
Hibernate: select b1_0.id,b1_0.author,b1_0.title,b1_0.version from book b1_0 where b1_0.id=?
binding parameter (1:BIGINT) <- [1]
after refresh(): Book{id=1, title=Clean Code (2nd Edition), author=Robert C. Martin, version=1} -- the local edit is gone, no exception was thrown