109 lines
7.0 KiB
Markdown
109 lines
7.0 KiB
Markdown
# 02 — merge() vs refresh()
|
|
|
|
[← Previous: 01 — get() vs load()](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/).
|
|
|
|
Source: [`MergeVsRefreshRunner`](../src/main/java/com/ankurm/hibernatedemo/scenario/MergeVsRefreshRunner.java),
|
|
entity: [`Book`](../src/main/java/com/ankurm/hibernatedemo/model/Book.java) (note the `@Version`
|
|
column — this scenario specifically needs a real optimistic-lock field, not just a plain one).
|
|
Run it yourself with `./scripts/run.sh mergerefresh`; the full transcript is committed at
|
|
[`docs/output/merge-vs-refresh.txt`](output/merge-vs-refresh.txt).
|
|
|
|
## The setup
|
|
|
|
1. Seed one `Book` row, `version=0`.
|
|
2. Load it, then close the session — it is now **detached**, still holding `version=0`.
|
|
3. A second, independent session loads the same row, edits the title, and commits. The row's
|
|
`version` in the database is now `1`.
|
|
4. Back on the original detached instance (still at `version=0`), edit a *different* field
|
|
(`author`) and call `session.merge(detached)`.
|
|
5. Separately: load the row fresh into a **managed** session, make an in-memory edit, don't
|
|
flush it, then call `session.refresh(...)`.
|
|
|
|
## What actually happened
|
|
|
|
Step 4 does not silently overwrite the title from step 3. It throws:
|
|
|
|
```
|
|
jakarta.persistence.OptimisticLockException: Row was already updated or deleted by another
|
|
transaction for entity [com.ankurm.hibernatedemo.model.Book with id '1']
|
|
```
|
|
|
|
`merge()` re-fetches the current row, sees the database is at `version=1` while the detached
|
|
instance is still carrying `version=0`, and refuses the write. The title edited by the "other
|
|
process" in step 3 survives untouched.
|
|
|
|
Step 5 is the opposite outcome for a structurally similar situation. The managed entity has an
|
|
unflushed local edit (`author` set to a new value that was never sent to the database).
|
|
`refresh()` re-runs the `SELECT` and overwrites every field with what's in the database right
|
|
now — including the field with the pending edit. No exception. No warning. The edit is just
|
|
gone:
|
|
|
|
```
|
|
before refresh(): Book{id=1, title=Clean Code (2nd Edition), author=SOMEONE ELSE ENTIRELY (never flushed), version=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
|
|
```
|
|
|
|
## What surprised me building this
|
|
|
|
Going in, the plan was to show "merge() can silently overwrite concurrent changes" as the
|
|
headline risk — that's the framing most write-ups of `merge()` use. Running it against a
|
|
`@Version`-ed entity showed the opposite: with optimistic locking in place, `merge()` is the
|
|
*safe* one here — it throws rather than clobbering someone else's committed write. `refresh()`
|
|
is the one that destroys data silently, and it does it to an edit that was never even sent to
|
|
the database yet. The risk isn't "which method can overwrite the database" — both can, that's
|
|
their job. It's "which method fails loudly when the state it's holding is stale," and on a
|
|
versioned entity that is `refresh()`, not `merge()` — precisely backwards from how the pairing
|
|
usually gets described. Strip the `@Version` column out and this flips: an unversioned `merge()`
|
|
would apply the stale write in step 4 without a peep. The column is not incidental to the
|
|
result; it's the whole reason the result is what it is.
|
|
|
|
## Full captured transcript
|
|
|
|
```console
|
|
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
|
|
```
|
|
|
|
[← Previous: 01 — get() vs load()](01-get-vs-load.md) | [Next: 03 — Inserting objects →](03-inserting-objects.md)
|