How does the entity version property work when using JPA and Hibernate

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

In this article, I’m going to show you how the JPA @Version entity property works when using Hibernate.

The most significant benefit of adding a version property to a JPA entity is that we can prevent the lost update anomaly, therefore ensuring that data integrity is not compromised.

Domain Model

Let’s consider we have the following Product entity in our application:

@Entity(name = "Product")
@Table(name = "product")
public class Product {

    @Id
    private Long id;

    private int quantity;

    @Version
    private int version;

    //Getters and setters omitted for brevity
}

Notice the version property uses the JPA @Version annotation which instructs Hibernate that this property will be used for the optimistic locking mechanism.

Persisting the entity

When persisting a Product entity:

Product product = new Product();
product.setId(1L);

entityManager.persist(product);

Hibernate will use the initial version value of 0 which is automatically assigned by the JVM since the version property is a primitive integer value.

INSERT INTO product (
    quantity, 
    version, 
    id
) 
VALUES (
    0, 
    0, 
    1
)

Updating the entity

When fetching and modifying the Product entity:

Product product = entityManager.find(
    Product.class, 
    1L
);

product.setQuantity(5);

Hibernate uses the version property in the WHERE clause of the executing UPDATE statement:

UPDATE 
    product 
SET 
    quantity = 5, 
    version = 1 
WHERE 
    id = 1 AND 
    version = 0

All the INSERT, UPDATE and DELETE statements executed by Hibernate are done via the executeUpdate method of the JDBC PreparedStatement object.

The executeUpdate method returns an integer which represents the number of records affected by the DML statements. In our case, we expect a value of 1 since there is only one Product entity having the provided identifier. More, by including the version property we check if the entity that we have previously loaded hasn’t changed in between the read and write operations.

So, if the returned value is not 1, then a StaleStateExcetion is thrown, which will be wrapped in a JPA OptimisticLockException when bootstrapping Hibernate using JPA.

The only two situations when the returned value is not 1 are if the entity was either modified, in which case the version did not match, or if the entity was deleted, hence the record could not be found at all.

Deleting the entity

When deleting a versioned entity:

Product product = entityManager.getReference(
    Product.class, 
    1L
);

entityManager.remove(product);

Hibernate is going to use the version property in the WHERE clause of the associated DELETE statement:

DELETE FROM 
    product 
WHERE 
    id = 1 AND 
    version = 1

Preventing lost updates

To understand how the version property can help you prevent lost updates, consider the following example:

Optimistic locking in long conversations using a managed entity that uses the version property

This example can be summarized as follows:

  1. Alice loads a Product entity which has a quantity value of 5 and a version of 1.
  2. A batch processor job updates the Product quantity to 0 and the version is now 2.
  3. Alice tries to buy a new Product, hence the Product quantity is decreased.
  4. When Alice’s EntityManager is flushed, the UPDATE is going to be executed using the old version value, hence an OptimisticLockException will be thrown because the Product version has changed.

This example is encapsulated by the following test case:

Product product = entityManager.find(Product.class, 1L);

executeSync(() -> doInJPA(_entityManager -> {
    LOGGER.info("Batch processor updates product stock");
    
    Product _product = _entityManager.find(
        Product.class, 
        1L
    );
    _product.setQuantity(0);
}));

LOGGER.info("Changing the previously loaded Product entity");
product.setQuantity(4);

When executing the test case above, Hibernate executes the following SQL statements:

DEBUG [Alice]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    SELECT 
        p.id as id1_0_0_, 
        p.quantity as quantity2_0_0_, 
        p.version as version3_0_0_ 
    FROM 
        product p 
    WHERE 
        p.id = ?
"], 
Params:[(
    1
)]

-- Batch processor updates product stock

DEBUG [Bob]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    SELECT 
        p.id as id1_0_0_, 
        p.quantity as quantity2_0_0_, 
        p.version as version3_0_0_ 
    FROM 
        product p 
    WHERE 
        p.id = ?
"], 
Params:[(
    1
)]

DEBUG [Bob]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    UPDATE 
        product 
    SET 
        quantity = ?, 
        version = ? 
    WHERE 
        id=? AND 
        version=?
"], 
Params:[(
    0, 
    2, 
    1, 
    1
)]

-- Changing the previously loaded Product entity

DEBUG [Alice]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    UPDATE 
        product 
    SET 
        quantity = ?, 
        version = ? 
    WHERE 
        id=? AND 
        version=?
"], 
Params:[(
    4, 
    2, 
    1, 
    1
)]

ERROR [Alice]: o.h.i.ExceptionMapperStandardImpl - HHH000346: 
Error during managed flush [Row was updated or deleted by another transaction 
(or unsaved-value mapping was incorrect) : 
[com.vladmihalcea.book.hpjp.hibernate.concurrency.version.Product#1]]

Notice that Alice’s UPDATE fails because the version column value has changed.

Merging the entity

The version is taken into consideration when merging a detached entity, as illustrated by the following example.

Optimistic locking in long conversations when merging a detached entity that uses the version property

This example can be summarized as follows:

  1. Alice loads a Product entity which has a quantity value of 5 and a version of 1.
  2. A batch processor job updates the Product quantity to 0 and the version is now 2.
  3. Alice tries to buy a new Product, hence the Product quantity is decreased.
  4. When Alice tries to merge the detached Product entity, an OptimisticLockException will be thrown because the Product version has changed.

The following test case encapsulates all the aforementioned logic:

String productJsonString = doInJPA(entityManager -> {
    return JacksonUtil.toString(
        entityManager.find(
            Product.class, 
            1L
        )
    );
});

executeSync(() -> doInJPA(entityManager -> {
    LOGGER.info("Batch processor updates product stock");

    Product product = entityManager.find(
        Product.class,
        1L
    );
    
    product.setQuantity(0);
}));

LOGGER.info("Changing the previously loaded Product entity");

ObjectNode productJsonNode = (ObjectNode) JacksonUtil
.toJsonNode(productJsonString);

int quantity  = productJsonNode.get("quantity").asInt();

productJsonNode.put(
    "quantity", 
    String.valueOf(--quantity)
);

doInJPA(entityManager -> {
    LOGGER.info("Merging the Product entity");

    Product product = JacksonUtil.fromString(
        productJsonNode.toString(),
        Product.class
    );
    entityManager.merge(product);
});

When executing the test case above, Hibernate the following SQL statements:

DEBUG [Alice]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    SELECT 
        p.id as id1_0_0_, 
        p.quantity as quantity2_0_0_, 
        p.version as version3_0_0_ 
    FROM 
        product p 
    WHERE 
        p.id = ?
"], 
Params:[(
    1
)]

-- Batch processor updates product stock

DEBUG [Bob]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    SELECT 
        p.id as id1_0_0_, 
        p.quantity as quantity2_0_0_, 
        p.version as version3_0_0_ 
    FROM 
        product p 
    WHERE 
        p.id = ?
"], 
Params:[(
    1
)]

DEBUG [Bob]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    UPDATE 
        product 
    SET 
        quantity = ?, 
        version = ? 
    WHERE 
        id=? AND 
        version=?
"], 
Params:[(
    0, 
    2, 
    1, 
    1
)]

-- Changing the previously loaded Product entity

-- Merging the Product entity

DEBUG [Alice]: n.t.d.l.SLF4JQueryLoggingListener - 
Query:["
    SELECT 
        p.id as id1_0_0_, 
        p.quantity as quantity2_0_0_, 
        p.version as version3_0_0_ 
    FROM 
        product p 
    WHERE 
        p.id = ?
"], 
Params:[(
    1
)]

ERROR [Alice]: c.v.b.h.h.c.v.VersionTest - Throws
javax.persistence.OptimisticLockException: 
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)
at org.hibernate.internal.ExceptionConverterImpl.wrapStaleStateException(ExceptionConverterImpl.java:226)
    at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:93)
    at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:181)
    at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:188)
    at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:917)
    at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:891)

So, when trying to merge the detached Product entity, Hibernate first loads the current database snapshot and attaches the loading-time state into the current Persistence Context. When copying the detached entity state onto the newly loaded Product entity, Hibernate detects that the version has changed, hence it throws the OptimisticLOckException right away, therefore skipping the UPDATE which would have failed with the same exception.

Changing the entity version

Trying to set the version of an entity to a specific value is a mistake because the default optimistic locking mechanism does not take into consideration the version from the entity Java object but from the loading-time snapshot.

More, if you try to change the version, the dirty checking mechanism will trigger a useless UPDATE.

So, when executing the following test case:

Product product = entityManager.find(
    Product.class, 
    1L
);

product.setVersion(100);

Hibernate generates an UPDATE statement that only increments the version while leaving all the other columns unchanged (their values are identical to the ones that were previously loaded from the database):

UPDATE 
    product 
SET 
    quantity = 5, 
    version = 2 
WHERE 
    id = 1 AND 
    version = 1

If you want to force an entity version change, you need to use either OPTIMISTIC_FORCE_INCREMENT or PESSIMISTIC_FORCE_INCREMENT.

Note that the default UPDATE includes all the columns associated with the current entity. This allows Hibernate to batch DML statements automatically and to benefit from statement caching as well.

If you want the UPDATE statement to include just the columns that have been modified, you need to use the @DynamicUpdate annotation at the entity level.

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

The @Version annotation allows Hibernate to activate the optimistic locking mechanism whenever executing an UPDATE or a DELETE statement against the entity in question.

By using the optimistic locking mechanism, you can prevent lost updates both when the entity is attached to the current Persistence Context or when the entity has been modified in the detached state.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

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