1
0
Files
hibernate-demo/docs/02-merge-vs-refresh.md

7.0 KiB

02 — merge() vs refresh()

← Previous: 01 — get() vs load() | Next: 03 — Inserting objects →

Backs ankurm.com: merge() vs refresh().

Source: MergeVsRefreshRunner, entity: Book (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.

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

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() | Next: 03 — Inserting objects →