Skip to main content

Hibernate 8 and Jakarta Persistence 4: EntityAgent and Stateless Sessions Explained (Tested on 8.0.0.CR1)

What Hibernate 8’s EntityAgent is, how it differs from EntityManager, and what it really does on 8.0.0.CR1: no dirty checking, lazy fetch(), bulk inserts, lock modes, programmatic result set mappings, and where H2 and HSQLDB disagree. Every figure comes from a committed test run.

You load a book, change its title, commit — and the database still has the old title. Or you insert five thousand rows in a loop and watch the heap grow until the batch job falls over. Both problems have the same root: the object in your hand is being tracked, or not tracked, by something you cannot see. Hibernate has had a second kind of session for years, StatelessSession, that does no tracking at all. Jakarta Persistence 4 takes that idea into the standard under a new name: EntityAgent. Hibernate 8 is the first Hibernate to implement it. This article builds a small project against the real jars, shows the SQL each call produces, and says plainly where the current pre-release builds still fall over.
Versions tested. Hibernate ORM 8.0.0.CR1 — the first release candidate, on Maven Central since 12 November 2026 (the Hibernate downloads page still listed Beta4 when I checked, so hibernate-core on Maven Central is the better source). Jakarta Persistence API 4.0.0-M8 — still a milestone; the Jakarta Persistence 4.0 specification page says final is targeted for late 2026. JDK Temurin 25.0.5+8, H2 2.5.252, HSQLDB 2.7.4 (comparison runs only), JUnit 6.1.3, Maven 3.9.11.

The Hibernate ORM 8.0 release page warns that new 8.0 features may still change in incompatible ways before the stable release. Every behaviour below is pinned by an assertion in the companion repository, so a later build that changes it fails the build instead of quietly making this article wrong.
Every code block links to the file it came from in the companion repository, and every console block is copied from a file in its output/ folder — written by a test that asserts the same numbers.

Why an edit made through an EntityAgent silently does nothing

With a normal EntityManager you rarely call an update method. You load an object, change a field, commit, and Hibernate writes the UPDATE. That works because the EntityManager keeps a persistence context: a memory of every object it loaded, plus a snapshot of what each one looked like at load time. At commit it compares object and snapshot and writes the differences. (This comparison is called dirty checking.) An EntityAgent has no such memory. It is a thin layer over SQL: get is a SELECT, update is an UPDATE, and nothing happens that you did not ask for. That one difference explains everything else in this article.
EntityManager (stateful) Your code Persistence contextholds Book 978-1 + a snapshotcompares them at commit Database find, edit SELECT, UPDATE EntityAgent (stateless) Your code no persistence contextnothing remembers anything Database get, update SELECT, UPDATE The middle box is what noticed your edit. Take it away and saving the edit becomes your job.
The diagram shows where the change tracking lives. In the top row the persistence context sits between your code and the database and is the part that notices an edit. In the bottom row that box is gone, so an edit to a Java object is invisible to everything except your own code. Here is the same three-line edit, first through an EntityManager and then through an EntityAgent. Both are in Demo01Test.java.
lab.sf.runInTransaction(em -> {
    Book b = em.find(Book.class, "978-1");
    b.title = "Changed via EntityManager";
});
lab.sf.runInTransaction(EntityAgent.class, a -> {
    Book b = a.get(Book.class, "978-2");
    b.title = "Changed via EntityAgent";
});
The test records every SQL statement Hibernate prepares and writes it to output/01-entitymanager-vs-entityagent.txt. Read the statement counts first, then the last line of each block:
EntityManager: find, edit title, commit (2 statements)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
  select version from final table (update book set price=?,publisher_id=?,title=?,version=?,views=? where isbn=? and version=?)
title in database afterwards: Changed via EntityManager

EntityAgent: get, edit title, commit (1 statement)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
title in database afterwards: Book 2

EntityAgent: get, edit title, update(book), commit (2 statements)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
  select version from final table (update book set version=case when price is distinct from ? or publisher_id is distinct from ? or title is distinct from ? then ? else version end,price=?,publisher_id=?,title=?,views=? where isbn=? and version=?)
title in database afterwards: Changed via EntityAgent + update()
The EntityManager run issued a SELECT and then an UPDATE that nobody asked for. The EntityAgent run issued only the SELECT, and a fresh read afterwards still shows Book 2: the edit vanished. Adding one explicit call fixes it (the third case in Demo01Test.java):
lab.sf.runInTransaction(EntityAgent.class, a -> {
    Book b = a.get(Book.class, "978-2");
    b.title = "Changed via EntityAgent + update()";
    a.update(b);
});
There is no symptom to catch. Nothing throws and nothing is logged. The only sign is a row that never changed. If you move code from an EntityManager to an EntityAgent for speed, every “load, modify, commit” path has to gain an explicit update().
Intermediate: why the two UPDATE statements look different

The SQL above is H2-specific: Hibernate wraps the update in select version from final table (update ...) so the new version comes back in the same round trip. Compare the two statements in output/01-entitymanager-vs-entityagent.txt and you will see the EntityAgent update writes the version through a case when ... is distinct from ? expression, while the EntityManager update sets it unconditionally. I did not investigate why the two paths differ, so treat that as an observation, not an explanation.

What matters for your code is the contract: on the EntityManager path the UPDATE only runs for objects dirty checking flagged; on the EntityAgent path it runs for whatever you pass to update(), whether or not anything changed.

Going deeper

EntityAgent is Hibernate’s StatelessSession with a standard name

If you already know StatelessSession, you already know most of EntityAgent. The 8.0 release notes describe EntityAgent as the standardised form of Hibernate’s StatelessSession. I checked that directly rather than trusting the sentence, by reading both interfaces from the jars with javap.
jakarta.persistence.EntityHandlerget, find, queries, statements jakarta.persistence.EntityManager jakarta.persistence.EntityAgent org.hibernate.Session org.hibernate.StatelessSession Arrows point from a child to the interface it extends (checked with javap).
The hierarchy is symmetrical. Jakarta Persistence 4 puts the shared operations (get, find, queries, statements) in a new parent interface, EntityHandler. EntityManager and EntityAgent both extend it, and Hibernate’s Session and StatelessSession extend them in turn. So existing StatelessSession code is already EntityAgent code. The practical differences are in the verbs each side adds on top of EntityHandler:
EntityManagerEntityAgent
Remembers loaded objectsyes (persistence context)no
Save a new rowpersistinsert
Change a rowedit the object; flushupdate (explicit)
Remove a rowremovedelete
Insert or updatemergeupsert
Load by idfind, getfind, get
Load a lazy associationon first accessfetch, on request
To try it you need one dependency. Everything else is the Hibernate 8 programmatic bootstrap shown below it, which is how the test harness builds its SessionFactory on in-memory H2 (see Lab.java and pom.xml).
<dependency>
  <groupId>org.hibernate.orm</groupId>
  <artifactId>hibernate-core</artifactId>
  <version>${hibernate.version}</version>
</dependency>
this.sf = new HibernatePersistenceConfiguration("lab")
        .managedClasses(Book.class, Publisher.class, Setting.class)
        .jdbcUrl(db.url())
        .jdbcCredentials("sa", "")
        .property("jakarta.persistence.schema-generation.database.action", "create")
        .property("hibernate.jdbc.batch_size", String.valueOf(jdbcBatchSize))
        .collectStatistics(true)
        .statementInspector(s -> {
            sql.add(s);
            return s;
        })
        .createEntityManagerFactory();
Asking that factory for an agent and printing what you get back is the quickest way to confirm the relationship (Demo08Test.java):
createEntityAgent() returns: org.hibernate.internal.StatelessSessionImpl
instanceof org.hibernate.StatelessSession: true
You are depending on a milestone spec. Hibernate 8.0.0.CR1 resolved jakarta.persistence-api 4.0.0-M8 transitively. A release candidate sitting on a milestone API is fine for learning and for testing your own code, but the method names in EntityAgent could still move before the spec is final.
Intermediate: what the 8.0 migration guide says changed for StatelessSession (from the docs)

This list comes from the Hibernate 8.0 migration guide and I have not run every item. Where I did verify something, I say so.

  • insert() returns void on EntityAgent; Hibernate’s old StatelessSession returned the generated identifier. javap confirms insert(Object) is void on CR1. The generated id is now read from the entity after the call (shown in output/04-bulk-insert.txt).
  • fetch() returns the fetched value instead of void. Confirmed with javap: <T> T fetch(T).
  • StatelessSession now throws the exception types the JPA specification mandates, and a unique-key violation on insert is an EntityExistsException. I ran the duplicate-key case: see output/08-upsert-delete-and-errors.txt.
  • Stateless updates and deletes reject a detached entity whose tenant identifier is missing or wrong, before any SQL runs (not run here).
  • Session.replicate() and ReplicationMode were removed; the guide points at upsert() for some import workflows (not run here).
  • Bean Validation: pre-persist validation is now empty by default and pre-insert is the event to use, especially with a stateless session (not run here).
Reference: option types on EntityAgent (from the docs, not run)

The factory has createEntityAgent(EntityAgent.CreationOption...) and the agent has addOption(EntityAgent.Option) (both confirmed with javap; EntityAgent.Option extends CreationOption). The What’s New page lists Hibernate types that can be passed this way, such as TenantId, ReadOnlyMode, FlushMode and CacheMode. I did not exercise any of them.

Going deeper

get() throws, find() returns null, and there is no cache to save you

EntityHandler has both get and find, and they differ only when the row is missing. find returns null. get throws EntityNotFoundException. Both calls come from Demo02Test.java.
Book missing = a.find(Book.class, "no-such-isbn");
EntityNotFoundException e = assertThrows(EntityNotFoundException.class,
        () -> a.get(Book.class, "no-such-isbn"));
The second difference matters more. Ask an EntityManager for the same id twice and the second answer comes from memory. An agent has nothing in memory, so it asks the database twice and hands back two different objects:
Book first = a.get(Book.class, "978-1");
Book second = a.get(Book.class, "978-1");
The transcript (output/02-get-find-and-no-cache.txt) puts the two handlers side by side:
agent.find(missing)  -> null
agent.get(missing)   -> EntityNotFoundException

EntityAgent, same id twice in one transaction:
  same instance? false
  SQL (2 statements)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?

EntityManager, same id twice in one transaction:
  same instance? true
  SQL (1 statement)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=?

agent.getMultiple(3 ids) returned 3 books
  SQL (1 statement)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn in (?,?,?)
Loading several ids together is one call, also from Demo02Test.java:
List<Book> books = lab.sf.callInTransaction(EntityAgent.class,
        a -> a.getMultiple(Book.class, List.of("978-1", "978-2", "978-3")));
Repeated loads are real queries. Code that quietly relied on the persistence context to avoid repeat SELECTs will hit the database every time under an agent. Load once and keep the object, or load several ids together with getMultiple, which produced a single select ... where isbn in (?,?,?) above.
Intermediate: how a lock gets requested on get()

get(Class, Object, FindOption...) takes find options, and LockModeType implements FindOption (both read with javap). That is how a lock is requested in the same call as the load; the lock-modes section below uses it.

Going deeper

Lazy associations stay lazy until you call fetch()

A Book points at a Publisher, and a Publisher owns a list of books. Both links are lazy. On an EntityManager the first access loads them for you. An agent does not: you ask for them explicitly with fetch(). The test is Demo03Test.java.
Book b = a.get(Book.class, "978-1");
boolean init = Hibernate.isInitialized(b.publisher);
After that get, the publisher is an uninitialised proxy. Loading it is one call, and the same works for the collection:
Publisher p = a.fetch(b.publisher);
var books = a.fetch(p.books);
agent.get(Book): publisher initialized? false
book.publisher.getName() before fetch -> LazyInitializationException
agent.fetch(book.publisher) -> getName() = Manning
  same object as book.publisher? true, initialized now? true
  reading the public field p.name instead -> null
  SQL (1 statement)
  select p1_0.id,p1_0.name from publisher p1_0 where p1_0.id=?

agent.get(Publisher): books collection initialized? false
agent.fetch(publisher.books) -> 3 books
  SQL (1 statement)
  select b1_0.publisher_id,b1_0.isbn,b1_0.price,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.publisher_id=?

The transcript is output/03-lazy-associations-and-fetch.txt. Reading getName() on the proxy before fetch() threw LazyInitializationException even though the agent was still open and inside its transaction. After fetch() the very same object (same object as book.publisher? true) is initialised and each fetch cost exactly one SELECT.

A public field on a proxy reads as null. My first draft of this test read p.name instead of p.getName() and printed null right after a successful fetch(). A Hibernate proxy is a generated subclass; reading a public field on it reads the subclass’s own empty field instead of calling through to the real object. This is ordinary Hibernate proxy behaviour, not something new in 8, but it is easy to hit when you are juggling objects that came from fetch(). Use getters.

Going deeper

  • Demo03Test.java — the full test, including the deliberate public-field read
  • I did not test cascading (persist-style cascades on an agent) at all; check before assuming it works either way.

Bulk inserts: where a stateless handler earns its keep

The reason StatelessSession exists is volume. Every object an EntityManager persists stays in the persistence context until it is cleared or closed, so a loop that inserts thousands of rows holds thousands of objects. An agent holds none.
Entities held in memory after inserting 5,000 books EntityManager.persist5,000 EntityAgent.insert0 SQL statements prepared for the same 5,000 rows (H2, batch size 50) EntityManager.persist1 EntityAgent.insert in a loop5,000 EntityAgent.insertMultiple1 The stateless handler holds nothing, but one insert() per row prepared a statement per row. insertMultiple() got both: no persistence context and a single prepared statement.
The first group of bars is memory: after 5,000 persist calls the EntityManager was tracking all 5,000, while the agent tracked nothing. The second group is a surprise, and it is why the diagram has three bars: calling insert() row by row prepared 5,000 statements, which looks like no JDBC batching at all (I inferred that from the prepare count and did not measure round trips), while insertMultiple() prepared one. The loops are in Demo04Test.java. First the row-by-row version, then the list version:
lab.sf.runInTransaction(EntityAgent.class, a -> {
    for (int i = 0; i < N; i++) {
        a.insert(new Book("ag-" + i, "Title " + i, "9.99"));
    }
});
lab.sf.runInTransaction(EntityAgent.class, a -> a.insertMultiple(batch));
EntityManager.persist x 5000 in one transaction
  entities held in the persistence context at the end: 5000
  entity inserts: 5000   statements prepared: 1

EntityAgent.insert x 5000 in one transaction
  entities held anywhere: 0 (a StatelessSession has no persistence context)
  entity inserts: 5000   statements prepared: 5000

EntityAgent.insertMultiple(list of 5000)
  entity inserts: 5000   statements prepared: 1

publisher.id before insert: null
publisher.id after insert:  1 (insert() itself returns void)
That is output/04-bulk-insert.txt. The last two lines also show the other insert() change from the migration guide: it returns nothing, and a generated id lands on the object instead (publisher.id was null before and 1 after).
Read the number for what it is. The measured figure is statements prepared from Hibernate’s statistics, on an in-memory H2 database, with hibernate.jdbc.batch_size=50. It is not round trips and not elapsed time; I did not time anything. The EntityManager row is the naive loop with no flush()/clear(), which is exactly the situation people move to a stateless session to escape.
Intermediate: how the numbers were collected

The persistence-context size is em.unwrap(Session.class).getStatistics().getEntityCount(). Statements prepared is sessionFactory.getStatistics().getPrepareStatementCount() with statistics switched on in the bootstrap. A StatementInspector in Lab.java also records the SQL text of every statement, which is where the SQL in the other transcripts comes from.

Going deeper

Lock modes: asking for OPTIMISTIC does more work than you expect

The 8.0 release notes say StatelessSession and EntityAgent now accept every LockModeType when loading an entity or running a query, that optimistic modes get their protection immediately instead of deferring the version check to commit, and that both *_FORCE_INCREMENT modes bump the version immediately. I wanted to see what “immediately” means in SQL, so the test opens a transaction by hand to look at what has run after get() and again after commit() (Demo05Test.java):
try (EntityAgent a = lab.sf.createEntityAgent()) {
    var tx = a.getTransaction();
    tx.begin();
    try {
        a.get(Book.class, "978-1", mode);
get(Book.class, id, mode) inside a transaction, observed on H2 OPTIMISTIC get(): SELECT … FOR UPDATErow is locked now commit(): nothing0 statements OPTIMISTIC_FORCE_INCREMENT get(): SELECT … FOR UPDATEthen UPDATE book SET versionversion 0 becomes 1 now commit(): nothing0 statements In both lanes all the database work happens at get(), not at commit(). HSQLDB behaved differently: see the output further down.
Both timelines show the same thing: all the database work happens at get(), and commit() has nothing left to do. The top lane is the surprising one. A plain OPTIMISTIC read ended with for update, a row lock.
=== H2 ===

get(Book.class, "978-1", OPTIMISTIC)
  right after get() (1 statement)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
  during commit (0 statements)
  version before: 0, after: 0

get(Book.class, "978-1", OPTIMISTIC_FORCE_INCREMENT)
  right after get() (2 statements)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
  update book set version=? where isbn=? and version=?
  during commit (0 statements)
  version before: 0, after: 1
That is the H2 half of output/05-lock-modes-in-entityagent.txt. The version column confirms the force-increment: 0 before and 1 after, with no update() call from my code. Then the same test on HSQLDB:
=== HSQLDB ===

get(Book.class, "978-1", OPTIMISTIC)
  FAILED: org.hibernate.HibernateException: Stateless optimistic locking requires transaction-long row protection; no strategy established for HSQLDialect:2:LOCKING

get(Book.class, "978-1", OPTIMISTIC_FORCE_INCREMENT)
  FAILED: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "owner" is null

get(Book.class, "978-1", PESSIMISTIC_WRITE)
  right after get() (1 statement)
  select b1_0.isbn,b1_0.price,b1_0.publisher_id,b1_0.title,b1_0.version,b1_0.views from book b1_0 where b1_0.isbn=? for update
  during commit (0 statements)
  version before: 0, after: 0

get(Book.class, "978-1", PESSIMISTIC_FORCE_INCREMENT)
  FAILED: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "owner" is null
Database support is not uniform yet. On HSQLDB, OPTIMISTIC failed with “Stateless optimistic locking requires transaction-long row protection; no strategy established for HSQLDialect:2:LOCKING”, which explains the H2 result: the database has to hold a row lock until commit for this mode to work. Both *_FORCE_INCREMENT modes on HSQLDB threw a NullPointerException inside Hibernate. PESSIMISTIC_WRITE worked. I have not reported the NullPointerException upstream; search the Hibernate issue tracker before relying on it.
Intermediate: what the OPTIMISTIC row lock means for you

In my runs an optimistic stateless read behaves like a pessimistic one at the SQL level, so it can block other writers and can deadlock like one. If you chose OPTIMISTIC to avoid locks, an EntityAgent may not give you that on every database. Test on your target database, not only on H2.

Going deeper

Updating a counter without invalidating everyone else’s version

A @Version column protects a row from lost updates, but some columns change constantly and harmlessly, such as a page-view counter. JPA 4 adds @ExcludedFromVersioning for those. The migration guide says an update() that changed only such attributes leaves the version alone. The entity is Book.java:
@Version
public int version;

/** Changing only this field must not bump {@link #version}. */
@ExcludedFromVersioning
public int views;
loaded: version=0 views=0
after update() that changed only views: version=0 views=42
after update() that changed title:     version=1 views=42
The transcript is output/06-excluded-from-versioning.txt: changing only views kept version=0, while changing title moved it to 1.
One case I did not test. I ran “only the excluded field changed” and “only a normal field changed”. What happens when one update() changes both is not covered by this repository.

Going deeper

Native queries without @SqlResultSetMapping

Native SQL results used to need an annotation on some entity. JPA 4 adds a programmatic API in jakarta.persistence.sql: build a ResultSetMapping in code and pass it to createNativeQuery. I listed the classes with javap and then ran three kinds: an entity, a record through its constructor, and a tuple (Demo07Test.java). The SQL renames a column on purpose so you can see the mapping doing its job:
String sql = "select isbn, title as book_title, price, version, views, publisher_id from book order by isbn";
ResultSetMapping<Book> asEntity = entity(Book.class,
        field(Book.class, String.class, "isbn", "isbn"),
        field(Book.class, String.class, "title", "book_title"),
        field(Book.class, BigDecimal.class, "price", "price"),
        field(Book.class, Integer.class, "version", "version"),
        field(Book.class, Integer.class, "views", "views"));
List<BookSummary> summaries = lab.sf.callInTransaction(EntityAgent.class,
        a -> a.createNativeQuery("select title, price from book order by isbn",
                constructor(BookSummary.class,
                        column("title", String.class),
                        column("price", BigDecimal.class))).getResultList());
entity mapping through EntityAgent: 3 rows, first = 978-1 / Book 1 / 10.00
entity mapping through EntityManager: 3 rows, managed by the persistence context? true

constructor mapping -> BookSummary[title=Book 1, price=10.00]
tuple mapping -> (978-1, 0)
The transcript is output/07-programmatic-result-set-mapping.txt. The same entity mapping works on both handlers; the difference is what you get back. Through the EntityManager the rows are managed by the persistence context (em.contains was true); through the agent they are plain detached objects.
The entity mapping still wants the foreign key. My first attempt selected only the five columns I mapped and failed with Column "PUBLISHER_ID" not found. Book has a lazy @ManyToOne, and Hibernate read the foreign key column even though I mapped no association. Adding publisher_id to the SELECT fixed it. I did not try mapping the association itself.
Reference: the mapping building blocks (read with javap)

Static factories on ResultSetMapping: column, constructor, compound, tuple, entity, embedded and field. field also has an overload taking a SingularAttribute from the static metamodel, which is the type-safe route the JPA 4 M1 announcement describes. EntityMapping has a withLockMode method. I only ran entity, constructor, column, field and tuple.

Going deeper

upsert(), delete() and what a duplicate key looks like

upsert() inserts a row or updates it if the key already exists. On an entity with no version column it worked as hoped on H2: one SQL statement, a merge into, for each call (Demo08Test.java).
a.upsert(new Setting("theme", "dark"));        // row exists
a.upsert(new Setting("language", "en"));       // row does not exist
createEntityAgent() returns: org.hibernate.internal.StatelessSessionImpl
instanceof org.hibernate.StatelessSession: true

upsert(theme=dark) on an existing row, upsert(language=en) on a new row (2 statements)
  merge into setting as t using (select cast(? as varchar(255)) name,cast(? as varchar(255)) val) as s on (t.name=s.name) when not matched then insert (name,val) values (s.name,s.val) when matched then update set val=s.val
  merge into setting as t using (select cast(? as varchar(255)) name,cast(? as varchar(255)) val) as s on (t.name=s.name) when not matched then insert (name,val) values (s.name,s.val) when matched then update set val=s.val
theme    -> dark
language -> en

insert() with an existing primary key -> jakarta.persistence.EntityExistsException
find() after delete() -> null
That is the whole of output/08-upsert-delete-and-errors.txt. The two upsert calls produced the same merge into statement, the existing row was updated (dark), the new row was inserted (en), a duplicate primary key on insert() raised jakarta.persistence.EntityExistsException, and find() after delete() returned null.
upsert() on a versioned entity did not work. The Setting entity above has no @Version. When I called upsert() on Book, which has one, it failed on both databases I tried. The next section has the exact exceptions.

Going deeper

The same operations on H2 and HSQLDB do not behave the same

The cleanest example of a pre-release rough edge is a stale update. Two handlers read the same row, one writes, and the other then tries to write its old copy. That must fail with an optimistic-lock error. I ran it on both databases, next to the same conflict through the EntityManager (Demo09Test.java):
String agentStale = attempt(() ->
        lab.sf.runInTransaction(EntityAgent.class, a -> a.update(stale)));
=== H2 ===
EntityAgent.update(stale book)      -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
EntityManager.merge(stale book)     -> jakarta.persistence.OptimisticLockException
EntityAgent.update(row not in db)   -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book
EntityAgent.upsert(new versioned)   -> org.hibernate.HibernateException: The database returned no natively generated values : com.ankurm.agent.Book

=== HSQLDB ===
EntityAgent.update(stale book)      -> jakarta.persistence.OptimisticLockException
EntityManager.merge(stale book)     -> jakarta.persistence.OptimisticLockException
EntityAgent.update(row not in db)   -> jakarta.persistence.OptimisticLockException
EntityAgent.upsert(new versioned)   -> jakarta.persistence.OptimisticLockException
That is output/09-same-operations-two-databases.txt. On HSQLDB everything fails the way you would hope: OptimisticLockException. On H2 the EntityManager path is correct, but the agent path throws a bare org.hibernate.HibernateException with the message “The database returned no natively generated values”. The same bare exception came back for updating a row that does not exist and for upsert() of a new versioned row.
Catch the wrong type and a conflict looks like a crash. If your retry logic catches OptimisticLockException, then on H2 with CR1 a stale EntityAgent.update() will sail past it. The H2 path wraps the update in select version from final table (...), and when no row matches it seems to treat the empty result as a failure to read generated values. That is my reading of the evidence, not a confirmed root cause. The H2 assertions in the test are pinned to this CR1 behaviour, so they will fail the day it is fixed.
Intermediate: how to run this against your own database

The portability test builds its factory through the small Lab.Db enum in Lab.java. To check a database you care about, add an entry with its JDBC URL and dependency, then run Demo05Test and Demo09Test. Those two are the ones where CR1 behaved differently between databases.

Going deeper

Should you use an EntityAgent at all?

Use the table as a first filter, not a rule:
SituationBetter fit
Loading or importing thousands of rowsEntityAgent with insertMultiple
Read, change, write one row that you controleither; the agent needs an explicit update()
Domain logic that edits an object graphEntityManager (dirty checking does the writes)
Code that loads the same entity repeatedlyEntityManager (it caches within the transaction)
Anything you cannot test on your production databasewait for Hibernate 8.0 final
My honest advice. Do not put Hibernate 8.0.0.CR1 in production. It is a release candidate on a milestone specification, and in my own runs it threw the wrong exception type on H2, rejected or crashed on lock modes on HSQLDB, and could not upsert a versioned entity. For most CRUD code the EntityManager is still the right tool, and its change tracking is a feature, not overhead. Reach for an agent when you have measured a bulk-write problem, and remember that if you are on Hibernate 7 today, StatelessSession already gives you most of the ideas here with slightly different method signatures.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.