How to batch DELETE statements with Hibernate

Introduction

In my previous post, I explained the Hibernate configurations required for batching INSERT and UPDATE statements. This post will continue this topic with DELETE statements batching.

Domain model entities

We’ll start with the following entity model:

PostCommentDetailsBatchDelete

The Post entity has a one-to-many association to a Comment and a one-to-one relationship with the PostDetails entity:

@OneToMany(cascade = CascadeType.ALL, mappedBy = "post",
        orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

@OneToOne(cascade = CascadeType.ALL, mappedBy = "post",
        orphanRemoval = true, fetch = FetchType.LAZY)
private PostDetails details;

The up-coming tests will be run against the following data:

doInTransaction(session -> {
    int batchSize = batchSize();
    for(int i = 0; i < itemsCount(); i++) {
        int j = 0;

        Post post = new Post(String.format(
            "Post no. %d", i));        
        post.addComment(new Comment( String.format(
            "Post comment %d:%d", i, j++)));
        post.addComment(new Comment(String.format(
            "Post comment %d:%d", i, j++)));
        post.addDetails(new PostDetails());

        session.persist(post);
        if(i % batchSize == 0 && i > 0) {
            session.flush();
            session.clear();
        }
    }
});

Hibernate Configuration

As already explained, the following properties are required for batching INSERT and UPDATE statements:

properties.put("hibernate.jdbc.batch_size", 
    String.valueOf(batchSize()));
properties.put("hibernate.order_inserts", 
    "true");
properties.put("hibernate.order_updates", 
    "true");
properties.put("hibernate.jdbc.batch_versioned_data", 
    "true");

Next, we are going to check if DELETE statements are batched as well.

JPA Cascade Delete

Because cascading entity state transitions is convenient, I’m going to prove that CascadeType.DELETE and JDBC batching don’t mix well.

The following tests is going to:

  • Select some Posts along with Comments and PostDetails
  • Delete the Posts, while propagating the delete event to Comments and PostDetails as well
@Test
public void testCascadeDelete() {
    LOGGER.info("Test batch delete with cascade");
    final AtomicReference<Long> startNanos = 
        new AtomicReference<>();
    addDeleteBatchingRows();
    doInTransaction(session -> {
        List<Post> posts = session.createQuery(
            "select distinct p " +
            "from Post p " +
            "join fetch p.details d " +
            "join fetch p.comments c")
        .list();
        startNanos.set(System.nanoTime());
        for (Post post : posts) {
            session.delete(post);
        }
    });
    LOGGER.info("{}.testCascadeDelete took {} millis",
        getClass().getSimpleName(),
        TimeUnit.NANOSECONDS.toMillis(
            System.nanoTime() - startNanos.get()
    ));
}

Running this test gives the following output:

Query:{[delete from Comment where id=? and version=?][55,0]} {[delete from Comment where id=? and version=?][56,0]} 
Query:{[delete from PostDetails where id=?][3]} 
Query:{[delete from Post where id=? and version=?][3,0]} 
Query:{[delete from Comment where id=? and version=?][54,0]} {[delete from Comment where id=? and version=?][53,0]} 
Query:{[delete from PostDetails where id=?][2]} 
Query:{[delete from Post where id=? and version=?][2,0]} 
Query:{[delete from Comment where id=? and version=?][52,0]} {[delete from Comment where id=? and version=?][51,0]} 
Query:{[delete from PostDetails where id=?][1]} 
Query:{[delete from Post where id=? and version=?][1,0]}

Only the Comment DELETE statements were batched, the other entities being deleted in separate database round-trips.

The reason for this behaviour is given by the ActionQueue sorting implementation:

if ( session.getFactory().getSettings().isOrderUpdatesEnabled() ) {
    // sort the updates by pk
    updates.sort();
}
if ( session.getFactory().getSettings().isOrderInsertsEnabled() ) {
    insertions.sort();
}

While INSERTS and UPDATES are covered, DELETE statements are not sorted at all. A JDBC batch can only be reused when all statements belong to the same database table. When an incoming statement targets a different database table, the current batch has to be released, so that the new batch matches the current statement database table:

public Batch getBatch(BatchKey key) {
    if ( currentBatch != null ) {
        if ( currentBatch.getKey().equals( key ) ) {
            return currentBatch;
        }
        else {
            currentBatch.execute();
            currentBatch.release();
        }
    }
    currentBatch = batchBuilder().buildBatch(key, this);
    return currentBatch;
}

Orphan removal and manual flushing

A work-around is to dissociate all Child entities while manually flushing the Hibernate Session before advancing to a new Child association:

@Test
public void testOrphanRemoval() {
    LOGGER.info("Test batch delete with orphan removal");
    final AtomicReference<Long> startNanos = 
        new AtomicReference<>();

    addDeleteBatchingRows();

    doInTransaction(session -> {
        List<Post> posts = session.createQuery(
            "select distinct p " +
            "from Post p " +
            "join fetch p.details d " +
            "join fetch p.comments c")
        .list();

        startNanos.set(System.nanoTime());

        posts.forEach(Post::removeDetails);
        session.flush();

        posts.forEach(post -> {
            for (Iterator<Comment> commentIterator = 
                     post.getComments().iterator(); 
                     commentIterator.hasNext(); ) {
                Comment comment =  commentIterator.next();
                comment.post = null;
                commentIterator.remove();
            }
        });
        session.flush();

        posts.forEach(session::delete);
    });
    LOGGER.info("{}.testOrphanRemoval took {} millis",
        getClass().getSimpleName(),
        TimeUnit.NANOSECONDS.toMillis(
            System.nanoTime() - startNanos.get()
    ));
}

This time all DELETE statements are properly batched:

Query:{[delete from PostDetails where id=?][2]} {[delete from PostDetails where id=?][3]} {[delete from PostDetails where id=?][1]} 
Query:{[delete from Comment where id=? and version=?][53,0]} {[delete from Comment where id=? and version=?][54,0]} {[delete from Comment where id=? and version=?][56,0]} {[delete from Comment where id=? and version=?][55,0]} {[delete from Comment where id=? and version=?][52,0]} {[delete from Comment where id=? and version=?][51,
Query:{[delete from Post where id=? and version=?][2,0]} {[delete from Post where id=? and version=?][3,0]} {[delete from Post where id=? and version=?][1,0]} 

SQL Cascade Delete

A better solution is to use SQL cascade deletion, instead of JPA entity state propagation mechanism. This way, we can also reduce the DML statements count. Because Hibernate Session acts as a transactional write-behind cache, we must be extra cautious when mixing entity state transitions with database-side automatic actions, as the Persistence Context might not reflect the latest database changes.

The Post entity one-to-many Comment association is marked with the Hibernate specific @OnDelete annotation, so that the auto-generated database schema includes the ON DELETE CASCADE directive:

@OneToMany(cascade = {
       CascadeType.PERSIST, 
       CascadeType.MERGE}, 
    mappedBy = "post")
@OnDelete(action = OnDeleteAction.CASCADE)
private List<Comment> comments = new ArrayList<>();

Generating the following DDL:

alter table Comment add constraint 
FK_apirq8ka64iidc18f3k6x5tc5 foreign key (post_id) 
references Post on delete cascade

The same is done with the PostDetails entity one-to-one Post association:

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id")
@MapsId
@OnDelete(action = OnDeleteAction.CASCADE)
private Post post;

And the associated DDL:

alter table PostDetails add constraint 
FK_h14un5v94coafqonc6medfpv8 foreign key (id) 
references Post on delete cascade

The CascadeType.ALL and orphanRemoval were replaced with CascadeType.PERSIST and CascadeType.MERGE, because we no longer want Hibernate to propagate the entity removal event.

The test only deletes the Post entities.

doInTransaction(session -> {
    List<Post> posts = session.createQuery(
        "select p from Post p")
    .list();
    startNanos.set(System.nanoTime());
    for (Post post : posts) {
        session.delete(post);
    }
});

The DELETE statements are properly batched as there’s only one target table.

Query:{[delete from Post where id=? and version=?][1,0]} {[delete from Post where id=? and version=?][2,0]} {[delete from Post where id=? and version=?][3,0]}

Conclusion

If INSERT and UPDATE statements batching is just a matter of configuration, DELETE statements require some additional steps, which may increase the data access layer complexity.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.

How to batch INSERT and UPDATE statements with Hibernate

Introduction

JDBC has long been offering support for DML statement batching. By default, all statements are sent one after the other, each one in a separate network round-trip. Batching allows us to send multiple statements in one-shot, saving unnecessary socket stream flushing.

Hibernate hides the database statements behind a transactional write-behind abstraction layer. An intermediate layer allows us to hide the JDBC batching semantics from the persistence layer logic. This way, we can change the JDBC batching strategy without altering the data access code.

Configuring Hibernate to support JDBC batching is not as easy as it should be, so I’m going to explain everything you need to do in order to make it work.

Testing time

We’ll start with the following entity model:

PostCommentJdbcBatch

The Post has a one-to-many association with the Comment entity:

@OneToMany(
    cascade = CascadeType.ALL, 
    mappedBy = "post", 
    orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

Or test scenario issues both INSERT and UPDATE statements, so we can validate if JDBC batching is being used:

LOGGER.info("Test batch insert");
long startNanos = System.nanoTime();
doInTransaction(session -> {
    int batchSize = batchSize();
    for(int i = 0; i < itemsCount(); i++) {
        Post post = new Post(
            String.format("Post no. %d", i)
        );
        int j = 0;
        post.addComment(new Comment(
                String.format(
                    "Post comment %d:%d", i, j++
        )));
        post.addComment(new Comment(
                String.format(
                     "Post comment %d:%d", i, j++
        )));
        session.persist(post);
        if(i % batchSize == 0 && i > 0) {
            session.flush();
            session.clear();
        }
    }
});
LOGGER.info("{}.testInsert took {} millis",
    getClass().getSimpleName(),
    TimeUnit.NANOSECONDS.toMillis(
        System.nanoTime() - startNanos
    ));

LOGGER.info("Test batch update");
startNanos = System.nanoTime();

doInTransaction(session -> {
    List<Post> posts = session.createQuery(
        "select distinct p " +
        "from Post p " +
        "join fetch p.comments c")
    .list();

    for(Post post : posts) {
        post.title = "Blog " + post.title;
        for(Comment comment : post.comments) {
            comment.review = "Blog " + comment.review;
        }
    }
});

LOGGER.info("{}.testUpdate took {} millis",
    getClass().getSimpleName(),
    TimeUnit.NANOSECONDS.toMillis(
        System.nanoTime() - startNanos
    ));

This test will persist a configurable number of Post entities, each one containing two Comments. For the sake of brevity, we are going to persist 3 Posts and the Dialect default batch size:

protected int itemsCount() {
    return 3;
}

protected int batchSize() {
    return Integer.valueOf(Dialect.DEFAULT_BATCH_SIZE);
}

Default batch support

Hibernate doesn’t implicitly employ JDBC batching and each INSERT and UPDATE statement is executed separately:

Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 0,0,1]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:0,0,51]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:1,0,52]} 
Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 1,0,2]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:0,0,53]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:1,0,54]} 
Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 2,0,3]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:0,0,55]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:1,0,56]}

Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 1,1,2,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:0,1,53,0]} 
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 0,1,1,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:1,1,52,0]} 
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 2,1,3,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:0,1,55,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:1,1,56,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:0,1,51,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:1,1,54,0]} 

Configuring hibernate.jdbc.batch_size

To enable JDBC batching, we have to configure the hibernate.jdbc.batch_size property:

A non-zero value enables use of JDBC2 batch updates by Hibernate (e.g. recommended values between 5 and 30)

We’ll set this property and rerun our test:

properties.put("hibernate.jdbc.batch_size", 
    String.valueOf(batchSize()));

This time, the Comment INSERT statements are batched, while the UPDATE statements are left untouched:

Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 0,0,1]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:0,0,51]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:1,0,52]} 
Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 1,0,2]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:0,0,53]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:1,0,54]} 
Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 2,0,3]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:0,0,55]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:1,0,56]}

Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 1,1,2,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:0,1,53,0]}
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 0,1,1,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:1,1,52,0]}
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 2,1,3,0]} 
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:0,1,55,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:1,1,56,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:0,1,51,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:1,1,54,0]}

A JDBC batch can target one table only, so every new DML statement targeting a different table ends up the current batch and initiates a new one. Mixing different table statements is therefore undesirable when using SQL batch processing.

Ordering statements

Hibernate can sort INSERT and UPDATE statements using the following configuration options:

properties.put("hibernate.order_inserts", "true");
properties.put("hibernate.order_updates", "true");

While the Post and Comment INSERT statements are batched accordingly, the UPDATE statements are still executed separately:

Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 0,0,1]} {[insert into Post (title, version, id) values (?, ?, ?)][Post no. 1,0,2]} {[insert into Post (title, version, id) values (?, ?, ?)][Post no. 2,0,3]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:0,0,51]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:1,0,52]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:0,0,53]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:1,0,54]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:0,0,55]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:1,0,56]}

Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:0,1,51,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:1,1,52,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:0,1,53,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:1,1,54,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:0,1,55,0]}
Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:1,1,56,0]}
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 0,1,1,0]} 
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 1,1,2,0]} 
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 2,1,3,0]} 

Adding version data batch support

There’s the hibernate.jdbc.batch_versioned_data configuration property we need to set, in order to enable UPDATE batching:

Set this property to true if your JDBC driver returns correct row counts from executeBatch(). It is usually safe to turn this option on. Hibernate will then use batched DML for automatically versioned data. Defaults to false.

We will rerun our test with this property set too:

properties.put("hibernate.jdbc.batch_versioned_data", "true");

Now both the INSERT and the UPDATE statements are properly batched:

Query:{[insert into Post (title, version, id) values (?, ?, ?)][Post no. 0,0,1]} {[insert into Post (title, version, id) values (?, ?, ?)][Post no. 1,0,2]} {[insert into Post (title, version, id) values (?, ?, ?)][Post no. 2,0,3]} 
Query:{[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:0,0,51]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][1,Post comment 0:1,0,52]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:0,0,53]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][2,Post comment 1:1,0,54]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:0,0,55]} {[insert into Comment (post_id, review, version, id) values (?, ?, ?, ?)][3,Post comment 2:1,0,56]}

Query:{[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:0,1,51,0]} {[update Comment set post_id=?, review=?, version=? where id=? and version=?][1,Blog Post comment 0:1,1,52,0]} {[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:0,1,53,0]} {[update Comment set post_id=?, review=?, version=? where id=? and version=?][2,Blog Post comment 1:1,1,54,0]} {[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:0,1,55,0]} {[update Comment set post_id=?, review=?, version=? where id=? and version=?][3,Blog Post comment 2:1,1,56,0]}
Query:{[update Post set title=?, version=? where id=? and version=?][Blog Post no. 0,1,1,0]} {[update Post set title=?, version=? where id=? and version=?][Blog Post no. 1,1,2,0]} {[update Post set title=?, version=? where id=? and version=?][Blog Post no. 2,1,3,0]} 

Benchmark

Now that we managed to configure Hibernate for JDBC batching, we can benchmark the performance gain of statement grouping.

  • the test case uses a PostgreSQL database installed on the same machine with the currently running JVM
  • a batch size of 50 was chosen and each test iteration increases the statement count by an order of magnitude
  • all durations are expressed in milliseconds
Statement count No batch Insert duration No batch Update duration Batch Insert duration Batch Update duration
30 218 178 191 144
300 311 327 208 217
3000 1047 1089 556 478
30000 5889 6032 2640 2301
300000 51785 57869 16052 20954

The more rows we INSERT or UPDATE, the more we can benefit from JDBC batching. For write-most applications (e.g enterprise enterprise batch processors), we should definitely enable JDBC batching as the performance benefits might be staggering.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.

Hibernate CascadeType.LOCK gotchas

Introduction

Having introduced Hibernate explicit locking support, as well as Cascade Types, it’s time to analyze the CascadeType.LOCK behavior.

A Hibernate lock request triggers an internal LockEvent. The associated DefaultLockEventListener may cascade the lock request to the locking entity children.

Since CascadeType.ALL includes CascadeType.LOCK too, it’s worth understanding when a lock request propagates from a Parent to a Child entity.

Testing time

We’ll start with the following entity model:

PostCommentDetailsLockCascade

The Post is the Parent entity of both the PostDetail one-to-one association and the Comment one-to-many relationship, and these associations are marked with CascadeType.ALL:

@OneToMany(
    cascade = CascadeType.ALL, 
    mappedBy = "post", 
    orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();

@OneToOne(
    cascade = CascadeType.ALL, 
    mappedBy = "post",
    fetch = FetchType.LAZY)
private PostDetails details;

All the up-coming test cases will use the following entity model graph:

doInTransaction(session -> {
    Post post = new Post();
    post.setName("Hibernate Master Class");

    post.addDetails(new PostDetails());
    post.addComment(new Comment("Good post!"));
    post.addComment(new Comment("Nice post!"));

    session.persist(post);
});

Locking managed entities

A managed entity is loaded in the current running Persistence Context and all entity state changes are translated to DML statements.

When a managed Parent entity is being locked:

doInTransaction(session -> {
    Post post = (Post) session.createQuery(
        "select p " +
        "from Post p " +
        "join fetch p.details " +
        "where " +
        "   p.id = :id")
    .setParameter("id", 1L)
    .uniqueResult();
    session.buildLockRequest(
        new LockOptions(LockMode.PESSIMISTIC_WRITE))
    .lock(post);
});

Only the Parent entity gets locked, the cascade being therefore prevented:

select id from Post where id = 1 for update

Hibernate defines a scope LockOption, which (according to JavaDocs) should allow a lock request to be propagated to Child entities:

“scope” is a JPA defined term. It is basically a cascading of the lock to associations.

session.buildLockRequest(
    new LockOptions(LockMode.PESSIMISTIC_WRITE))
.setScope(true)
.lock(post);

Setting the scope flag doesn’t change anything, only the managed entity being locked:

select id from Post where id = 1 for update

Locking detached entities

Apart from entity locking, the lock request can reassociate detached entities too. To prove this, we are going to check the Post entity graph before and after the lock entity request:

void containsPost(Session session, 
    Post post, boolean expected) {
    assertEquals(expected, 
        session.contains(post));
    assertEquals(expected, 
        session.contains(post.getDetails()));
    for(Comment comment : post.getComments()) {
        assertEquals(expected, 
            session.contains(comment));
    }
}

The following test demonstrates how CascadeType.LOCK works for detached entities:

//Load the Post entity, which will become detached
Post post = doInTransaction(session -> 
   (Post) session.createQuery(
        "select p " +
        "from Post p " +
        "join fetch p.details " +
        "join fetch p.comments " +
        "where " +
        "   p.id = :id")
.setParameter("id", 1L)
.uniqueResult());

//Change the detached entity state
post.setName("Hibernate Training");
doInTransaction(session -> {
    //The Post entity graph is detached
    containsPost(session, post, false);

    //The Lock request associates 
    //the entity graph and locks the requested entity
    session.buildLockRequest(
        new LockOptions(LockMode.PESSIMISTIC_WRITE))
    .lock(post);
    
    //Hibernate doesn't know if the entity is dirty
    assertEquals("Hibernate Training", 
        post.getName());

    //The Post entity graph is attached
    containsPost(session, post, true);
});
doInTransaction(session -> {
    //The detached Post entity changes have been lost
    Post _post = (Post) session.get(Post.class, 1L);
    assertEquals("Hibernate Master Class", 
        _post.getName());
});

The lock request reassociates the entity graph, but the current running Hibernate Session is unaware the entity became dirty, while in detached state. The entity is just reattached without forcing an UPDATE, or selecting the current database state for further comparison.

Once the entity becomes managed, any further change will be detected by the dirty checking mechanism and the flush will propagate the ante-reattachment changes as well. If no change happens while the entity is managed, the entity will not be scheduled for flushing.

If we want to make sure, the detached entity state is always synchronized with the database, we need to use merge or update.

The detached entities propagate the lock options, when the scope option is set to true:

session.buildLockRequest(
    new LockOptions(LockMode.PESSIMISTIC_WRITE))
.setScope(true)
.lock(post);

The Post entity lock event is propagated to all Child entities (since we are using CascadeType.ALL):

select id from Comment where id = 1 for update
select id from Comment where id = 2 for update
select id from PostDetails where id = 1 for update
select id from Post where id = 1 for update

Conclusion

The lock cascading is far from being straight-forward or intuitive. Explicit locking requires diligence (the more locks we acquire, the greater the chance of dead-locking) and you are better off retaining full-control over Child entity lock propagation anyway. Analogous to concurrency programming best practices, manual locking is therefore preferred over automatic lock propagation.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.

A beginner’s guide to JPA and Hibernate Cascade Types

Introduction

JPA translates entity state transitions to database DML statements. Because it’s common to operate on entity graphs, JPA allows us to propagate entity state changes from Parents to Child entities.

This behavior is configured through the CascadeType mappings.

JPA vs Hibernate Cascade Types

Hibernate supports all JPA Cascade Types and some additional legacy cascading styles. The following table draws an association between JPA Cascade Types and their Hibernate native API equivalent:

JPA EntityManager action JPA CascadeType Hibernate native Session action Hibernate native CascadeType Event Listener
detach(entity) DETACH evict(entity) DETACH or EVICT Default Evict Event Listener
merge(entity) MERGE merge(entity) MERGE Default Merge Event Listener
persist(entity) PERSIST persist(entity) PERSIST Default Persist Event Listener
refresh(entity) REFRESH refresh(entity) REFRESH Default Refresh Event Listener
remove(entity) REMOVE delete(entity) REMOVE or DELETE Default Delete Event Listener
saveOrUpdate(entity) SAVE_UPDATE Default Save Or Update Event Listener
replicate(entity, replicationMode) REPLICATE Default Replicate Event Listener
lock(entity, lockModeType) buildLockRequest(entity, lockOptions) LOCK Default Lock Event Listener
All the above EntityManager methods ALL All the above Hibernate Session methods ALL

From this table we can conclude that:

  • There’s no difference between calling persist, merge or refresh on the JPA EntityManager or the Hibernate Session.
  • The JPA remove and detach calls are delegated to Hibernate delete and evict native operations.
  • Only Hibernate supports replicate and saveOrUpdate. While replicate is useful for some very specific scenarios (when the exact entity state needs to be mirrored between two distinct DataSources), the persist and merge combo is always a better alternative than the native saveOrUpdate operation.

    As a rule of thumb, you should always use persist for TRANSIENT entities and merge for DETACHED ones.

    The saveOrUpdate shortcomings (when passing a detached entity snapshot to a Session already managing this entity) had lead to the merge operation predecessor: the now extinct saveOrUpdateCopy operation.

  • The JPA lock method shares the same behaviour with Hibernate lock request method.
  • The JPA CascadeType.ALL doesn’t only apply to EntityManager state change operations, but to all Hibernate CascadeTypes as well.

    So if you mapped your associations with CascadeType.ALL, you can still cascade Hibernate specific events. For example, you can cascade the JPA lock operation (although it behaves as reattaching, instead of an actual lock request propagation), even if JPA doesn’t define a CascadeType.LOCK.

Cascading best practices

Cascading only makes sense only for ParentChild associations (the Parent entity state transition being cascaded to its Child entities). Cascading from Child to Parent is not very useful and usually, it’s a mapping code smell.

Next, I’m going to take analyse the cascading behaviour of all JPA ParentChild associations.

One-To-One

The most common One-To-One bidirectional association looks like this:

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @OneToOne(mappedBy = "post",
        cascade = CascadeType.ALL, orphanRemoval = true)
    private PostDetails details;

    public Long getId() {
        return id;
    }

    public PostDetails getDetails() {
        return details;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void addDetails(PostDetails details) {
        this.details = details;
        details.setPost(this);
    }

    public void removeDetails() {
        if (details != null) {
            details.setPost(null);
        }
        this.details = null;
    }
}

@Entity
public class PostDetails {

    @Id
    private Long id;

    @Column(name = "created_on")
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdOn = new Date();

    private boolean visible;

    @OneToOne
    @JoinColumn(name = "id")
    @MapsId
    private Post post;

    public Long getId() {
        return id;
    }

    public void setVisible(boolean visible) {
        this.visible = visible;
    }

    public void setPost(Post post) {
        this.post = post;
    }
}

The Post entity plays the Parent role and the PostDetails is the Child.

The bidirectional associations should always be updated on both sides, therefore the Parent side should contain the addChild and removeChild combo. These methods ensure we always synchronize both sides of the association, to avoid object or relational data corruption issues.

In this particular case, the CascadeType.ALL and orphan removal make sense because the PostDetails life-cycle is bound to that of its Post Parent entity.

Cascading the one-to-one persist operation

The CascadeType.PERSIST comes along with the CascadeType.ALL configuration, so we only have to persist the Post entity, and the associated PostDetails entity is persisted as well:

Post post = new Post();
post.setName("Hibernate Master Class");

PostDetails details = new PostDetails();

post.addDetails(details);

session.persist(post);

Generating the following output:

INSERT INTO post(id, NAME) 
VALUES (DEFAULT, Hibernate Master Class'')

insert into PostDetails (id, created_on, visible) 
values (1, '2015-03-03 10:17:19.14', false)

Cascading the one-to-one merge operation

The CascadeType.MERGE is inherited from the CascadeType.ALL setting, so we only have to merge the Post entity and the associated PostDetails is merged as well:

Post post = newPost();
post.setName("Hibernate Master Class Training Material");
post.getDetails().setVisible(true);

doInTransaction(session -> {
    session.merge(post);
});

The merge operation generates the following output:

SELECT onetooneca0_.id     AS id1_3_1_,
   onetooneca0_.NAME       AS name2_3_1_,
   onetooneca1_.id         AS id1_4_0_,
   onetooneca1_.created_on AS created_2_4_0_,
   onetooneca1_.visible    AS visible3_4_0_
FROM   post onetooneca0_
LEFT OUTER JOIN postdetails onetooneca1_ 
    ON onetooneca0_.id = onetooneca1_.id
WHERE  onetooneca0_.id = 1

UPDATE postdetails SET 
    created_on = '2015-03-03 10:20:53.874', visible = true 
WHERE  id = 1

UPDATE post SET 
    NAME = 'Hibernate Master Class Training Material' 
WHERE  id = 1

Cascading the one-to-one delete operation

The CascadeType.REMOVE is also inherited from the CascadeType.ALL configuration, so the Post entity deletion triggers a PostDetails entity removal too:

Post post = newPost();

doInTransaction(session -> {
    session.delete(post);
});

Generating the following output:

delete from PostDetails where id = 1
delete from Post where id = 1

The one-to-one delete orphan cascading operation

If a Child entity is dissociated from its Parent, the Child Foreign Key is set to NULL. If we want to have the Child row deleted as well, we have to use the orphan removal support.

doInTransaction(session -> {
    Post post = (Post) session.get(Post.class, 1L);
    post.removeDetails();
});

The orphan removal generates this output:

SELECT onetooneca0_.id         AS id1_3_0_,
       onetooneca0_.NAME       AS name2_3_0_,
       onetooneca1_.id         AS id1_4_1_,
       onetooneca1_.created_on AS created_2_4_1_,
       onetooneca1_.visible    AS visible3_4_1_
FROM   post onetooneca0_
LEFT OUTER JOIN postdetails onetooneca1_
    ON onetooneca0_.id = onetooneca1_.id
WHERE  onetooneca0_.id = 1

delete from PostDetails where id = 1

Unidirectional one-to-one association

Most often, the Parent entity is the inverse side (e.g. mappedBy), the Child controlling the association through its Foreign Key. But the cascade is not limited to bidirectional associations, we can also use it for unidirectional relationships:

@Entity
public class Commit {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String comment;

    @OneToOne(cascade = CascadeType.ALL)
    @JoinTable(
        name = "Branch_Merge_Commit",
        joinColumns = @JoinColumn(
            name = "commit_id", 
            referencedColumnName = "id"),
        inverseJoinColumns = @JoinColumn(
            name = "branch_merge_id", 
            referencedColumnName = "id")
    )
    private BranchMerge branchMerge;

    public Commit() {
    }

    public Commit(String comment) {
        this.comment = comment;
    }

    public Long getId() {
        return id;
    }

    public void addBranchMerge(
        String fromBranch, String toBranch) {
        this.branchMerge = new BranchMerge(
             fromBranch, toBranch);
    }

    public void removeBranchMerge() {
        this.branchMerge = null;
    }
}

@Entity
public class BranchMerge {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String fromBranch;

    private String toBranch;

    public BranchMerge() {
    }

    public BranchMerge(
        String fromBranch, String toBranch) {
        this.fromBranch = fromBranch;
        this.toBranch = toBranch;
    }

    public Long getId() {
        return id;
    }
}

Cascading consists in propagating the Parent entity state transition to one or more Child entities, and it can be used for both unidirectional and bidirectional associations.

One-To-Many

The most common ParentChild association consists of a one-to-many and a many-to-one relationship, where the cascade being useful for the one-to-many side only:

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;

    @OneToMany(cascade = CascadeType.ALL, 
        mappedBy = "post", orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    public void setName(String name) {
        this.name = name;
    }

    public List<Comment> getComments() {
        return comments;
    }

    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }

    public void removeComment(Comment comment) {
        comment.setPost(null);
        this.comments.remove(comment);
    }
}

@Entity
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @ManyToOne
    private Post post;

    private String review;

    public void setPost(Post post) {
        this.post = post;
    }

    public String getReview() {
        return review;
    }

    public void setReview(String review) {
        this.review = review;
    }
}

Like in the one-to-one example, the CascadeType.ALL and orphan removal are suitable because the Comment life-cycle is bound to that of its Post Parent entity.

Cascading the one-to-many persist operation

We only have to persist the Post entity and all the associated Comment entities are persisted as well:

Post post = new Post();
post.setName("Hibernate Master Class");

Comment comment1 = new Comment();
comment1.setReview("Good post!");
Comment comment2 = new Comment();
comment2.setReview("Nice post!");

post.addComment(comment1);
post.addComment(comment2);

session.persist(post);

The persist operation generates the following output:

insert into Post (id, name) 
values (default, 'Hibernate Master Class')

insert into Comment (id, post_id, review) 
values (default, 1, 'Good post!')

insert into Comment (id, post_id, review) 
values (default, 1, 'Nice post!')

Cascading the one-to-many merge operation

Merging the Post entity is going to merge all Comment entities as well:

Post post = newPost();
post.setName("Hibernate Master Class Training Material");

post.getComments()
    .stream()
    .filter(comment -> comment.getReview().toLowerCase()
         .contains("nice"))
    .findAny()
    .ifPresent(comment -> 
        comment.setReview("Keep up the good work!")
);

doInTransaction(session -> {
    session.merge(post);
});

Generating the following output:

SELECT onetomanyc0_.id    AS id1_1_1_,
       onetomanyc0_.NAME  AS name2_1_1_,
       comments1_.post_id AS post_id3_1_3_,
       comments1_.id      AS id1_0_3_,
       comments1_.id      AS id1_0_0_,
       comments1_.post_id AS post_id3_0_0_,
       comments1_.review  AS review2_0_0_
FROM   post onetomanyc0_
LEFT OUTER JOIN comment comments1_
    ON onetomanyc0_.id = comments1_.post_id
WHERE  onetomanyc0_.id = 1

update Post set 
    name = 'Hibernate Master Class Training Material' 
where id = 1

update Comment set 
    post_id = 1, 
    review='Keep up the good work!' 
where id = 2

Cascading the one-to-many delete operation

When the Post entity is deleted, the associated Comment entities are deleted as well:

Post post = newPost();

doInTransaction(session -> {
    session.delete(post);
});

Generating the following output:

delete from Comment where id = 1
delete from Comment where id = 2
delete from Post where id = 1

The one-to-many delete orphan cascading operation

The orphan-removal allows us to remove the Child entity whenever it’s no longer referenced by its Parent:

newPost();

doInTransaction(session -> {
    Post post = (Post) session.createQuery(
        "select p " +
                "from Post p " +
                "join fetch p.comments " +
                "where p.id = :id")
        .setParameter("id", 1L)
        .uniqueResult();
    post.removeComment(post.getComments().get(0));
});

The Comment is deleted, as we can see in the following output:

SELECT onetomanyc0_.id    AS id1_1_0_,
       comments1_.id      AS id1_0_1_,
       onetomanyc0_.NAME  AS name2_1_0_,
       comments1_.post_id AS post_id3_0_1_,
       comments1_.review  AS review2_0_1_,
       comments1_.post_id AS post_id3_1_0__,
       comments1_.id      AS id1_0_0__
FROM   post onetomanyc0_
INNER JOIN comment comments1_
    ON onetomanyc0_.id = comments1_.post_id
WHERE  onetomanyc0_.id = 1

delete from Comment where id = 1

Many-To-Many

The many-to-many relationship is tricky because each side of this association plays both the Parent and the Child role. Still, we can identify one side from where we’d like to propagate the entity state changes.

We shouldn’t default to CascadeType.ALL, because the CascadeTpe.REMOVE might end-up deleting more than we’re expecting (as you’ll soon find out):

@Entity
public class Author {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    @Column(name = "full_name", nullable = false)
    private String fullName;

    @ManyToMany(mappedBy = "authors", 
        cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private List<Book> books = new ArrayList<>();

    private Author() {}

    public Author(String fullName) {
        this.fullName = fullName;
    }

    public Long getId() {
        return id;
    }

    public void addBook(Book book) {
        books.add(book);
        book.authors.add(this);
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.authors.remove(this);
    }

    public void remove() {
        for(Book book : new ArrayList<>(books)) {
            removeBook(book);
        }
    }
}

@Entity
public class Book {

    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;

    @Column(name = "title", nullable = false)
    private String title;

    @ManyToMany(cascade = 
        {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(name = "Book_Author",
        joinColumns = {
            @JoinColumn(
                name = "book_id", 
                referencedColumnName = "id"
            )
        },
        inverseJoinColumns = {
            @JoinColumn(
                name = "author_id", 
                referencedColumnName = "id"
            )
        }
    )
    private List<Author> authors = new ArrayList<>();

    private Book() {}

    public Book(String title) {
        this.title = title;
    }
}

Cascading the many-to-many persist operation

Persisting the Author entities will persist the Books as well:

Author _John_Smith = new Author("John Smith");
Author _Michelle_Diangello = 
    new Author("Michelle Diangello");
Author _Mark_Armstrong = 
    new Author("Mark Armstrong");

Book _Day_Dreaming = new Book("Day Dreaming");
Book _Day_Dreaming_2nd = 
    new Book("Day Dreaming, Second Edition");

_John_Smith.addBook(_Day_Dreaming);
_Michelle_Diangello.addBook(_Day_Dreaming);

_John_Smith.addBook(_Day_Dreaming_2nd);
_Michelle_Diangello.addBook(_Day_Dreaming_2nd);
_Mark_Armstrong.addBook(_Day_Dreaming_2nd);

session.persist(_John_Smith);
session.persist(_Michelle_Diangello);
session.persist(_Mark_Armstrong);

The Book and the Book_Author rows are inserted along with the Authors:

insert into Author (id, full_name) 
values (default, 'John Smith')

insert into Book (id, title) 
values (default, 'Day Dreaming')

insert into Author (id, full_name) 
values (default, 'Michelle Diangello')

insert into Book (id, title) 
values (default, 'Day Dreaming, Second Edition')

insert into Author (id, full_name) 
values (default, 'Mark Armstrong')

insert into Book_Author (book_id, author_id) values (1, 1)
insert into Book_Author (book_id, author_id) values (1, 2)
insert into Book_Author (book_id, author_id) values (2, 1)
insert into Book_Author (book_id, author_id) values (2, 2)
insert into Book_Author (book_id, author_id) values (3, 1)

Dissociating one side of the many-to-many association

To delete an Author, we need to dissociate all Book_Author relations belonging to the removable entity:

doInTransaction(session -> {
    Author _Mark_Armstrong =
        getByName(session, "Mark Armstrong");
    _Mark_Armstrong.remove();
    session.delete(_Mark_Armstrong);
});

This use case generates the following output:

SELECT manytomany0_.id        AS id1_0_0_,
       manytomany2_.id        AS id1_1_1_,
       manytomany0_.full_name AS full_nam2_0_0_,
       manytomany2_.title     AS title2_1_1_,
       books1_.author_id      AS author_i2_0_0__,
       books1_.book_id        AS book_id1_2_0__
FROM   author manytomany0_
INNER JOIN book_author books1_
	ON manytomany0_.id = books1_.author_id
INNER JOIN book manytomany2_
	ON books1_.book_id = manytomany2_.id
WHERE  manytomany0_.full_name = 'Mark Armstrong'

SELECT books0_.author_id  AS author_i2_0_0_,
       books0_.book_id    AS book_id1_2_0_,
       manytomany1_.id    AS id1_1_1_,
       manytomany1_.title AS title2_1_1_
FROM   book_author books0_
INNER JOIN book manytomany1_
    ON books0_.book_id = manytomany1_.id
WHERE  books0_.author_id = 2

delete from Book_Author where book_id = 2

insert into Book_Author (book_id, author_id) values (2, 1)
insert into Book_Author (book_id, author_id) values (2, 2)

delete from Author where id = 3 

The many-to-many association generates way too many redundant SQL statements and often, they are very difficult to tune. Next, I’m going to demonstrate the many-to-many CascadeType.REMOVE hidden dangers.

The many-to-many CascadeType.REMOVE gotchas

The many-to-many CascadeType.ALL is another code smell, I often bump into while reviewing code. The CascadeType.REMOVE is automatically inherited when using CascadeType.ALL, but the entity removal is not only applied to the link table, but to the other side of the association as well.

Let’s change the Author entity books many-to-many association to use the CascadeType.ALL instead:

@ManyToMany(mappedBy = "authors", 
    cascade = CascadeType.ALL)
private List<Book> books = new ArrayList<>();

When deleting one Author:

doInTransaction(session -> {
    Author _Mark_Armstrong = 
        getByName(session, "Mark Armstrong");
    session.delete(_Mark_Armstrong);
    Author _John_Smith = 
        getByName(session, "John Smith");
    assertEquals(1, _John_Smith.books.size());
});

All books belonging to the deleted Author are getting deleted, even if other Authors we’re still associated to the deleted Books:

SELECT manytomany0_.id        AS id1_0_,
       manytomany0_.full_name AS full_nam2_0_
FROM   author manytomany0_
WHERE  manytomany0_.full_name = 'Mark Armstrong'  

SELECT books0_.author_id  AS author_i2_0_0_,
       books0_.book_id    AS book_id1_2_0_,
       manytomany1_.id    AS id1_1_1_,
       manytomany1_.title AS title2_1_1_
FROM   book_author books0_
INNER JOIN book manytomany1_ ON 
       books0_.book_id = manytomany1_.id
WHERE  books0_.author_id = 3  

delete from Book_Author where book_id=2
delete from Book where id=2
delete from Author where id=3

Most often, this behavior doesn’t match the business logic expectations, only being discovered upon the first entity removal.

We can push this issue even further, if we set the CascadeType.ALL to the Book entity side as well:

@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "Book_Author",
    joinColumns = {
        @JoinColumn(
            name = "book_id", 
            referencedColumnName = "id"
        )
    },
    inverseJoinColumns = {
        @JoinColumn(
            name = "author_id", 
            referencedColumnName = "id"
        )
    }
)

This time, not only the Books are being deleted, but Authors are deleted as well:

doInTransaction(session -> {
    Author _Mark_Armstrong = 
        getByName(session, "Mark Armstrong");
    session.delete(_Mark_Armstrong);
    Author _John_Smith = 
        getByName(session, "John Smith");
    assertNull(_John_Smith);
});

The Author removal triggers the deletion of all associated Books, which further triggers the removal of all associated Authors. This is a very dangerous operation, resulting in a massive entity deletion that’s rarely the expected behavior.

SELECT manytomany0_.id        AS id1_0_,
       manytomany0_.full_name AS full_nam2_0_
FROM   author manytomany0_
WHERE  manytomany0_.full_name = 'Mark Armstrong'  

SELECT books0_.author_id  AS author_i2_0_0_,
       books0_.book_id    AS book_id1_2_0_,
       manytomany1_.id    AS id1_1_1_,
       manytomany1_.title AS title2_1_1_
FROM   book_author books0_
INNER JOIN book manytomany1_
   ON books0_.book_id = manytomany1_.id
WHERE  books0_.author_id = 3  

SELECT authors0_.book_id      AS book_id1_1_0_,
       authors0_.author_id    AS author_i2_2_0_,
       manytomany1_.id        AS id1_0_1_,
       manytomany1_.full_name AS full_nam2_0_1_
FROM   book_author authors0_
INNER JOIN author manytomany1_
   ON authors0_.author_id = manytomany1_.id
WHERE  authors0_.book_id = 2  

SELECT books0_.author_id  AS author_i2_0_0_,
       books0_.book_id    AS book_id1_2_0_,
       manytomany1_.id    AS id1_1_1_,
       manytomany1_.title AS title2_1_1_
FROM   book_author books0_
INNER JOIN book manytomany1_
   ON books0_.book_id = manytomany1_.id
WHERE  books0_.author_id = 1 

SELECT authors0_.book_id      AS book_id1_1_0_,
       authors0_.author_id    AS author_i2_2_0_,
       manytomany1_.id        AS id1_0_1_,
       manytomany1_.full_name AS full_nam2_0_1_
FROM   book_author authors0_
INNER JOIN author manytomany1_
   ON authors0_.author_id = manytomany1_.id
WHERE  authors0_.book_id = 1  

SELECT books0_.author_id  AS author_i2_0_0_,
       books0_.book_id    AS book_id1_2_0_,
       manytomany1_.id    AS id1_1_1_,
       manytomany1_.title AS title2_1_1_
FROM   book_author books0_
INNER JOIN book manytomany1_
   ON books0_.book_id = manytomany1_.id
WHERE  books0_.author_id = 2  

delete from Book_Author where book_id=2
delete from Book_Author where book_id=1
delete from Author where id=2
delete from Book where id=1
delete from Author where id=1 
delete from Book where id=2
delete from Author where id=3

This use case is wrong in so many ways. There are a plethora of unnecessary SELECT statements and eventually we end up deleting all Authors and all their Books. That’s why CascadeType.ALL should raise your eyebrow, whenever you spot it on a many-to-many association.

When it comes to Hibernate mappings, you should always strive for simplicity. The Hibernate documentation confirms this assumption as well:

Practical test cases for real many-to-many associations are rare. Most of the time you need additional information stored in the link table. In this case, it is much better to use two one-to-many associations to an intermediate link class. In fact, most associations are one-to-many and many-to-one. For this reason, you should proceed cautiously when using any other association style.

Conclusion

Cascading is a handy ORM feature, but it’s not free of issues. You should only cascade from Parent entities to Children and not the other way around. You should always use only the casacde operations that are demanded by your business logic requirements, and not turn the CascadeType.ALL into a default ParentChild association entity state propagation configuration.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.

Hibernate Locking Patterns – How do PESSIMISTIC_READ and PESSIMISTIC_WRITE work

Introduction

Java Persistence API comes with a thorough concurrency control mechanism, supporting both implicit and explicit locking. The implicit locking mechanism is straightforward and it relies on:

  • Optimistic locking: Entity state changes can trigger a version incrementation
  • Row-level locking: Based on the current running transaction isolation level, the INSERT/UPDATE/DELETE statements may acquire exclusive row locks

While implicit locking is suitable for many scenarios, an explicit locking mechanism can leverage a finer-grained concurrency control.

In my previous posts, I covered the explicit optimistic lock modes:

In this post, I am going to unravel the explicit pessimistic lock modes:

Readers–writer lock

A database system is a highly concurrent environment, therefore many concurrency theory idioms apply to database access as well. Concurrent changes must be serialized to preserve data integrity, so most database systems use a two-phase locking strategy, even if it’s usually supplemented by a Multiversion concurrency control mechanism.

Because a mutual exclusion locking would hinder scalability (treating reads and writes equally), most database systems use a readers-writer locking synchronization scheme, so that:

  • A shared (read) lock blocks writers, allowing multiple readers to proceed
  • An exclusive (write) lock blocks both readers and writers, making all write operations be applied sequentially

Because the locking syntax is not part of the SQL Standard, each RDBMS has opted for a different syntax:

Database name Shared lock statement Exclusive lock statement
Oracle FOR UPDATE FOR UPDATE
MySQL LOCK IN SHARE MODE FOR UPDATE
Microsoft SQL Server WITH (HOLDLOCK, ROWLOCK) WITH (UPDLOCK, ROWLOCK)
PostgreSQL FOR SHARE FOR UPDATE
DB2 FOR READ ONLY WITH RS FOR UPDATE WITH RS

Java Persistence abstraction layer hides the database specific locking semantics, offering a common API that only requires two Lock Modes. The shared/read lock is acquired using the PESSIMISTIC_READ Lock Mode Type, and the exclusive/write lock is requested using PESSIMISTIC_WRITE instead.

PostgreSQL row-level lock modes

For the next test cases, we are going to use PostgreSQL for it supports both exclusive and share explicit locking.

All the following tests will use the same concurrency utility, emulating two users: Alice and Bob. Each test scenario will verify a specific read/write locking combination.

private void testPessimisticLocking(ProductLockRequestCallable primaryLockRequestCallable, ProductLockRequestCallable secondaryLockRequestCallable) {
	doInTransaction(session -> {
		try {
			Product product = (Product) session.get(Product.class, 1L);
			primaryLockRequestCallable.lock(session, product);
			executeAsync(
				() -> {
					doInTransaction(_session -> {
						Product _product = (Product) _session.get(Product.class, 1L);
						secondaryLockRequestCallable.lock(_session, _product);
					});
				},
				endLatch::countDown
			);
			sleep(WAIT_MILLIS);
		} catch (StaleObjectStateException e) {
			LOGGER.info("Optimistic locking failure: ", e);
		}
	});
	awaitOnLatch(endLatch);
}

Case 1: PESSIMISTIC_READ doesn’t block PESSIMISTIC_READ lock requests

The first test will check how two concurrent PESSIMISTIC_READ lock requests interact:

@Test
public void testPessimisticReadDoesNotBlockPessimisticRead() throws InterruptedException {
	LOGGER.info("Test PESSIMISTIC_READ doesn't block PESSIMISTIC_READ");
	testPessimisticLocking(
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
			LOGGER.info("PESSIMISTIC_READ acquired");
		},
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
			LOGGER.info("PESSIMISTIC_READ acquired");
		}
	);
}

Running this test, we get the following output:

[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Test PESSIMISTIC_READ doesn't block PESSIMISTIC_READ

#Alice selects the Product entity
[Alice]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]} 

#Alice acquires a SHARED lock on the Product entity
[Alice]: Time:1 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR share 
][1,0]} 
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_READ acquired

#Alice waits for 500ms
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Wait 500 ms!

#Bob selects the Product entity
[Bob]: Time:1 Query:{[
 SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]}

#Bob acquires a SHARED lock on the Product entity
[Bob]: Time:1 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR share 
][1,0]} 
[Bob]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_READ acquired

#Bob's transactions is committed
[Bob]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Alice's transactions is committed
[Alice]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

In this scenario, there is no contention whatsoever. Both Alice and Bob can acquire a shared lock without running into any conflict.

Case 2: PESSIMISTIC_READ blocks UPDATE implicit lock requests

The second scenario will demonstrate how the shared lock prevents a concurrent modification. Alice will acquire a shared lock and Bob will attempt to modify the locked entity:

@Test
public void testPessimisticReadBlocksUpdate() throws InterruptedException {
	LOGGER.info("Test PESSIMISTIC_READ blocks UPDATE");
	testPessimisticLocking(
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
			LOGGER.info("PESSIMISTIC_READ acquired");
		},
		(session, product) -> {
			product.setDescription("USB Flash Memory Stick");
			session.flush();
			LOGGER.info("Implicit lock acquired");
		}
	);
}

The test generates this output:

[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Test PESSIMISTIC_READ blocks UPDATE

#Alice selects the Product entity
[Alice]: Time:0 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]} 

#Alice acquires a SHARED lock on the Product entity
[Alice]: Time:0 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR share 
][1,0]} 
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_READ acquired

#Alice waits for 500ms
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Wait 500 ms!

#Bob selects the Product entity
[Bob]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]} 

#Alice's transactions is committed
[Alice]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Bob can acquire the Product entity lock, only after Alice's transaction is committed
[Bob]: Time:427 Query:{[
UPDATE product
SET    description = ?,
       price = ?,
       version = ?
WHERE  id = ?
       AND version = ?
][USB Flash Memory Stick,12.99,1,1,0]} 
[Bob]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Implicit lock acquired

#Bob's transactions is committed
[Bob]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

While Bob could select the Product entity, the UPDATE is delayed up until Alice transaction is committed (that’s why the UPDATE took 427ms to run).

Case 3: PESSIMISTIC_READ blocks PESSIMISTIC_WRITE lock requests

The same behaviour is exhibited by a secondary PESSIMISTIC_WRITE lock request:

@Test
public void testPessimisticReadBlocksPessimisticWrite() throws InterruptedException {
	LOGGER.info("Test PESSIMISTIC_READ blocks PESSIMISTIC_WRITE");
	testPessimisticLocking(
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
			LOGGER.info("PESSIMISTIC_READ acquired");
		},
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_WRITE)).lock(product);
			LOGGER.info("PESSIMISTIC_WRITE acquired");
		}
	);
}

Giving the following output:

[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Test PESSIMISTIC_READ blocks PESSIMISTIC_WRITE

#Alice selects the Product entity
[Alice]: Time:0 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]}

#Alice acquires a SHARED lock on the Product entity
[Alice]: Time:1 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR share 
][1,0]} 
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_READ acquired

#Alice waits for 500ms
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Wait 500 ms!

#Bob selects the Product entity
[Bob]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]} 

#Alice's transactions is committed
[Alice]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Bob can acquire the Product entity lock, only after Alice's transaction is committed
[Bob]: Time:428 Query:{[
SELECT id
FROM   product
WHERE  id = ?
       AND version = ?
FOR UPDATE  
][1,0]} 
[Bob]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_WRITE acquired

#Bob's transactions is committed
[Bob]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

Bob’s exclusive lock request waits for Alice’s shared lock to be released.

Case 4: PESSIMISTIC_READ blocks PESSIMISTIC_WRITE lock requests, NO WAIT fails fast

Hibernate provides a PESSIMISTIC_NO_WAIT timeout directive, which translates to a database specific NO_WAIT lock acquire policy.

The PostgreSQL NO WAIT directive is described as follows:

To prevent the operation from waiting for other transactions to commit, use the NOWAIT option. With NOWAIT, the statement reports an error, rather than waiting, if a selected row cannot be locked immediately. Note that NOWAIT applies only to the row-level lock(s) — the required ROW SHARE table-level lock is still taken in the ordinary way (see Chapter 13). You can use LOCK with the NOWAIT option first, if you need to acquire the table-level lock without waiting.

@Test
public void testPessimisticReadWithPessimisticWriteNoWait() throws InterruptedException {
	LOGGER.info("Test PESSIMISTIC_READ blocks PESSIMISTIC_WRITE, NO WAIT fails fast");
	testPessimisticLocking(
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
			LOGGER.info("PESSIMISTIC_READ acquired");
		},
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_WRITE)).setTimeOut(Session.LockRequest.PESSIMISTIC_NO_WAIT).lock(product);
			LOGGER.info("PESSIMISTIC_WRITE acquired");
		}
	);
}

This test generates the following output:

[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Test PESSIMISTIC_READ blocks PESSIMISTIC_WRITE, NO WAIT fails fast

#Alice selects the Product entity
[Alice]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]}

#Alice acquires a SHARED lock on the Product entity
[Alice]: Time:1 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR share 
][1,0]} 
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_READ acquired

#Alice waits for 500ms
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Wait 500 ms!

#Bob selects the Product entity
[Bob]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]}

#Bob tries to acquire an EXCLUSIVE lock on the Product entity and fails because of the NO WAIT policy
[Bob]: Time:0 Query:{[
SELECT id
FROM   product
WHERE  id = ?
       AND version = ?
FOR UPDATE nowait
][1,0]} 
[Bob]: o.h.e.j.s.SqlExceptionHelper - SQL Error: 0, SQLState: 55P03
[Bob]: o.h.e.j.s.SqlExceptionHelper - ERROR: could not obtain lock on row in relation "product"

#Bob's transactions is rolled back
[Bob]: o.h.e.t.i.j.JdbcTransaction - rolled JDBC Connection

#Alice's transactions is committed
[Alice]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

Since Alice already holds a shared lock on the Product entity associated database row, Bob’s exclusive lock request fails immediately.

Case 5: PESSIMISTIC_WRITE blocks PESSIMISTIC_READ lock requests

The next test proves that an exclusive lock will always blocks a shared lock acquire attempt:

@Test
public void testPessimisticWriteBlocksPessimisticRead() throws InterruptedException {
	LOGGER.info("Test PESSIMISTIC_WRITE blocks PESSIMISTIC_READ");
	testPessimisticLocking(
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_WRITE)).lock(product);
			LOGGER.info("PESSIMISTIC_WRITE acquired");
		},
		(session, product) -> {
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
			LOGGER.info("PESSIMISTIC_WRITE acquired");
		}
	);
}

Generating the following output:

[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Test PESSIMISTIC_WRITE blocks PESSIMISTIC_READ

#Alice selects the Product entity
[Alice]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]} 

#Alice acquires an EXCLUSIVE lock on the Product entity
[Alice]: Time:0 Query:{[
SELECT id
FROM   product
WHERE  id = ?
       AND version = ?
FOR UPDATE  
][1,0]} 
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_WRITE acquired

#Alice waits for 500ms
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Wait 500 ms!

#Bob selects the Product entity
[Bob]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ? 
][1]}

#Alice's transactions is committed
[Alice]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Bob can acquire the Product entity SHARED lock, only after Alice's transaction is committed
[Bob]: Time:428 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR share 
][1,0]}
[Bob]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_WRITE acquired

#Bob's transactions is committed
[Bob]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

Bob’s shared lock request waits for Alice’s transaction to end, so that all acquired locks are released.

Case 6: PESSIMISTIC_WRITE blocks PESSIMISTIC_WRITE lock requests

An exclusive lock blocks an exclusive lock as well:

@Test
public void testPessimisticWriteBlocksPessimisticWrite() throws InterruptedException {
	LOGGER.info("Test PESSIMISTIC_WRITE blocks PESSIMISTIC_WRITE");
	testPessimisticLocking(
			(session, product) -> {
				session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_WRITE)).lock(product);
				LOGGER.info("PESSIMISTIC_WRITE acquired");
			},
			(session, product) -> {
				session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_WRITE)).lock(product);
				LOGGER.info("PESSIMISTIC_WRITE acquired");
			}
	);
}

The test generates this output:

[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Test PESSIMISTIC_WRITE blocks PESSIMISTIC_WRITE

#Alice selects the Product entity
[Alice]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ?  
][1]} 

#Alice acquires an EXCLUSIVE lock on the Product entity
[Alice]: Time:0 Query:{[
SELECT id
FROM   product
WHERE  id = ?
       AND version = ?
FOR UPDATE  
][1,0]} 
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_WRITE acquired

#Alice waits for 500ms
[Alice]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - Wait 500 ms!

#Bob selects the Product entity
[Bob]: Time:1 Query:{[
SELECT lockmodepe0_.id          AS id1_0_0_,
       lockmodepe0_.description AS descript2_0_0_,
       lockmodepe0_.price       AS price3_0_0_,
       lockmodepe0_.version     AS version4_0_0_
FROM   product lockmodepe0_
WHERE  lockmodepe0_.id = ? 
][1]}

#Alice's transactions is committed
[Alice]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Bob can acquire the Product entity SHARED lock, only after Alice's transaction is committed
[Bob]: Time:428 Query:{[
SELECT id
FROM   product
WHERE  id =?
AND    version =? FOR update 
][1,0]}
[Bob]: c.v.h.m.l.c.LockModePessimisticReadWriteIntegrationTest - PESSIMISTIC_WRITE acquired

#Bob's transactions is committed
[Bob]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

Bob’s exclusive lock request has to wait for Alice to release its lock.

Conclusion

Relational database systems use locks for preserving ACID guarantees, so it’s important to understand how shared and exclusive row-level locks inter-operate. An explicit pessimistic lock is a very powerful database concurrency control mechanism and you might even use it for fixing an optimistic locking race condition.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.

Hibernate locking patterns – How does PESSIMISTIC_FORCE_INCREMENT Lock Mode work

Introduction

In my previous post, I introduced the OPTIMISTIC_FORCE_INCREMENT Lock Mode and we applied it for propagating a child entity version change to a locked parent entity. In this post, I am going to reveal the PESSIMISTIC_FORCE_INCREMENT Lock Mode and compare it with its optimistic counterpart.

More alike than different

As we already found out, the OPTIMISTIC_FORCE_INCREMENT Lock Mode can increment an entity version, even when the current transaction doesn’t modify the locked entity state. For each lock mode, Hibernate defines an associated LockingStrategy and the OPTIMISTIC_FORCE_INCREMENT Lock Mode event is handled by the OptimisticForceIncrementLockingStrategy:

public class OptimisticForceIncrementLockingStrategy implements LockingStrategy {

	//code omitted for brevity

	@Override
	public void lock(Serializable id, Object version, Object object, int timeout, SessionImplementor session) {
		if ( !lockable.isVersioned() ) {
			throw new HibernateException( "[" + lockMode + "] not supported for non-versioned entities [" + lockable.getEntityName() + "]" );
		}
		final EntityEntry entry = session.getPersistenceContext().getEntry( object );
		// Register the EntityIncrementVersionProcess action to run just prior to transaction commit.
		( (EventSource) session ).getActionQueue().registerProcess( new EntityIncrementVersionProcess( object, entry ) );
	}
}

This strategy registers an EntityIncrementVersionProcess in the current Persistence Context action queue. The locked entity version gets incremented just before completing the current running transaction.

public class EntityIncrementVersionProcess implements BeforeTransactionCompletionProcess {
	
	//code omitted for brevity
	
	@Override
	public void doBeforeTransactionCompletion(SessionImplementor session) {
		final EntityPersister persister = entry.getPersister();
		final Object nextVersion = persister.forceVersionIncrement( entry.getId(), entry.getVersion(), session );
		entry.forceLocked( object, nextVersion );
	}
}

Analogous to OPTIMISTIC_FORCE_INCREMENT, the PESSIMISTIC_FORCE_INCREMENT Lock Mode is handled by the PessimisticForceIncrementLockingStrategy:

public class PessimisticForceIncrementLockingStrategy implements LockingStrategy {

	//code omitted for brevity

	@Override
	public void lock(Serializable id, Object version, Object object, int timeout, SessionImplementor session) {
		if ( !lockable.isVersioned() ) {
			throw new HibernateException( "[" + lockMode + "] not supported for non-versioned entities [" + lockable.getEntityName() + "]" );
		}
		final EntityEntry entry = session.getPersistenceContext().getEntry( object );
		final EntityPersister persister = entry.getPersister();
		final Object nextVersion = persister.forceVersionIncrement( entry.getId(), entry.getVersion(), session );
		entry.forceLocked( object, nextVersion );
	}
}

The locked entity is incremented right away, so these two lock modes execute the same logic but at different times. The PESSIMISTIC_FORCE_INCREMENT naming might lead you into thinking that you are using a pessimistic locking strategy, while in reality this Lock Mode is just an optimistic locking variation.

Pessimistic locking entails explicit physical locks (shared or exclusive), while optimistic locking relies on current transaction isolation level implicit locking instead.

The Repository Use Case

I am going to reuse the previous post exercise and switch to using the PESSIMISTIC_FORCE_INCREMENT Lock Mode. To recap a little bit, our domain model contains:

  • a Repository entity, whose version is increased with every new Commit
  • a Commit entity, encapsulating a single atomical Repository state transition
  • a CommitChange component, encapsulating a single Repository resource change

Concurrent modification prevention

Our system is simultaneously accessed by both Alice and Bob. The Repository entity is always locked, right after it’s being fetched from the database:

private final CountDownLatch startLatch = new CountDownLatch(1);
private final CountDownLatch endLatch = new CountDownLatch(1);

@Test
public void testConcurrentPessimisticForceIncrementLockingWithLockWaiting() throws InterruptedException {
	LOGGER.info("Test Concurrent PESSIMISTIC_FORCE_INCREMENT Lock Mode With Lock Waiting");
	doInTransaction(session -> {
		try {
			Repository repository = (Repository) session.get(Repository.class, 1L);
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_FORCE_INCREMENT)).lock(repository);

			executeAsync(() -> doInTransaction(_session -> {
				LOGGER.info("Try to get the Repository row");
				startLatch.countDown();
				Repository _repository = (Repository) _session.get(Repository.class, 1L);
				_session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_FORCE_INCREMENT)).lock(_repository);
				Commit _commit = new Commit(_repository);
				_commit.getChanges().add(new Change("index.html", "0a1,2..."));
				_session.persist(_commit);
				_session.flush();
				endLatch.countDown();
			}));
			startLatch.await();
			LOGGER.info("Sleep for 500ms to delay the other transaction PESSIMISTIC_FORCE_INCREMENT Lock Mode acquisition");
			Thread.sleep(500);
			Commit commit = new Commit(repository);
			commit.getChanges().add(new Change("README.txt", "0a1,5..."));
			commit.getChanges().add(new Change("web.xml", "17c17..."));
			session.persist(commit);
		} catch (InterruptedException e) {
			fail("Unexpected failure");
		}
	});
	endLatch.await();
}

This test case generates the following output:

#Alice selects the Repository
Query:{[select lockmodeop0_.id as id1_2_0_, lockmodeop0_.name as name2_2_0_, lockmodeop0_.version as version3_2_0_ from repository lockmodeop0_ where lockmodeop0_.id=?][1]} 

#Alice locks the Repository using a PESSIMISTIC_FORCE_INCREMENT Lock Mode
Query:{[update repository set version=? where id=? and version=?][1,1,0]} 

#Bob tries to get the Repository but the SELECT is blocked by Alice lock 
INFO  [pool-1-thread-1]: c.v.h.m.l.c.LockModePessimisticForceIncrementTest - Try to get the Repository row

#Alice sleeps for 500ms to prove that Bob is waiting for her to release the acquired lock
Sleep for 500ms to delay the other transaction PESSIMISTIC_FORCE_INCREMENT Lock Mode acquisition

#Alice makes two changes and inserts a new Commit<a href="https://vladmihalcea.files.wordpress.com/2015/02/explicitlockingpessimisticforceincrementfailfast.png"><img src="https://vladmihalcea.files.wordpress.com/2015/02/explicitlockingpessimisticforceincrementfailfast.png?w=585" alt="ExplicitLockingPessimisticForceIncrementFailFast" width="585" height="224" class="alignnone size-large wp-image-3955" /></a>
Query:{[insert into commit (id, repository_id) values (default, ?)][1]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][1,0a1,5...,README.txt]#The Repository version is bumped up to version 1 and a conflict is raised
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][1,17c17...,web.xml]} Query:{[update repository set version=? where id=? and version=?][1,1,0]}

#Alice commits the transaction, therefore releasing all locks
DEBUG [main]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Bob Repository SELECT can proceed 
Query:{[select lockmodepe0_.id as id1_2_0_, lockmodepe0_.name as name2_2_0_, lockmodepe0_.version as version3_2_0_ from repository lockmodepe0_ where lockmodepe0_.id=?][1]} 

#Bob can insert his changes
Query:{[update repository set version=? where id=? and version=?][2,1,1]} 
Query:{[insert into commit (id, repository_id) values (default, ?)][1]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][2,0a1,2...,index.html]} 

This locking process can be easily visualize din the following diagram:

ExplicitLockingPessimisticForceIncrement

The HSQLDB test database Two-Phase Locking implementation uses course grain table locks whenever a database row is modified.

That’s the reason why Bob is unable to get the read lock on the Repository database row that Alice has just updated. Other databases (e.g. Oracle, PostgreSQL) use MVCC, therefore allowing a SELECT to proceed (using the current modifying transaction undo logs for recreating the previous row state) while blocking conflicting data modifying statements (e.g. updating the Repository row, when other concurrent transaction hasn’t yet committed the locked entity state change).

Fail fast

The instantaneous version incrementation has some interesting benefits:

  • If the version UPDATE succeeds (the exclusive row level lock is acquired), no other concurrent transaction can modify the locked database row. This is the moment when the logical lock (the version incrementation) is upgraded to a physical lock (the database exclusive lock).
  • If the version UPDATE fails (because some other concurrent transaction already committed a version change), our current running transaction can be rolled back at once (as opposed to waiting for the transaction to fail during commit)

The latter use case can be visualized as follows:

ExplicitLockingPessimisticForceIncrementFailFast

For this scenario, we are going to use the following test case:

@Test
public void testConcurrentPessimisticForceIncrementLockingFailFast() throws InterruptedException {
	LOGGER.info("Test Concurrent PESSIMISTIC_FORCE_INCREMENT Lock Mode fail fast");
	doInTransaction(session -> {
		try {
			Repository repository = (Repository) session.get(Repository.class, 1L);

			executeSync(() -> {
				doInTransaction(_session -> {
					Repository _repository = (Repository) _session.get(Repository.class, 1L);
					_session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_FORCE_INCREMENT)).lock(_repository);
					Commit _commit = new Commit(_repository);
					_commit.getChanges().add(new Change("index.html", "0a1,2..."));
					_session.persist(_commit);
					_session.flush();
				});
			});
			session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_FORCE_INCREMENT)).lock(repository);
			fail("Should have thrown StaleObjectStateException!");
		} catch (StaleObjectStateException expected) {
			LOGGER.info("Failure: ", expected);
		}
	});
}

Generating the following output:

#Alice selects the Repository
Query:{[select lockmodeop0_.id as id1_2_0_, lockmodeop0_.name as name2_2_0_, lockmodeop0_.version as version3_2_0_ from repository lockmodeop0_ where lockmodeop0_.id=?][1]} 

#Bob selects the Repository too
Query:{[select lockmodepe0_.id as id1_2_0_, lockmodepe0_.name as name2_2_0_, lockmodepe0_.version as version3_2_0_ from repository lockmodepe0_ where lockmodepe0_.id=?][1]} 

#Bob locks the Repository using a PESSIMISTIC_FORCE_INCREMENT Lock Mode
Query:{[update repository set version=? where id=? and version=?][1,1,0]} 

#Bob makes a change and inserts a new Commit
Query:{[insert into commit (id, repository_id) values (default, ?)][1]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][1,0a1,2...,index.html]} 

#Bob commits the transaction
DEBUG [pool-3-thread-1]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

#Alice tries to lock the Repository
Query:{[update repository set version=? where id=? and version=?][1,1,0]} 

#Alice cannot lock the Repository, because the version has changed
INFO  [main]: c.v.h.m.l.c.LockModePessimisticForceIncrementTest - Failure: 
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.vladmihalcea.hibernate.masterclass.laboratory.concurrency.LockModePessimisticForceIncrementTest$Repository#1]

Conclusion

Like OPTIMISTIC_FORCE_INCREMENT, the PESSIMISTIC_FORCE_INCREMENT Lock Mode is useful for propagating an entity state change to a parent entity.

While the locking mechanism is similar, the PESSIMISTIC_FORCE_INCREMENT is applied on the spot, allowing the current running transaction to instantaneously evaluate the locking outcome.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.

Hibernate locking patterns – How does OPTIMISTIC_FORCE_INCREMENT Lock Mode work

Introduction

In my previous post, I explained how OPTIMISTIC Lock Mode works and how it can help us synchronize external entity state changes. In this post, we are going to unravel the OPTIMISTIC_FORCE_INCREMENT Lock Mode usage patterns.

With LockModeType.OPTIMISTIC, the locked entity version is checked towards the end of the current running transaction, to make sure we don’t use a stale entity state. Because of the application-level validation nature, this strategy is susceptible to race-conditions, therefore requiring an additional pessimistic lock .

The LockModeType.OPTIMISTIC_FORCE_INCREMENT not only it checks the expected locked entity version, but it also increments it. Both the check and the update happen in the same UPDATE statement, therefore making use of the current database transaction isolation level and the associated physical locking guarantees.

It is worth noting that the locked entity version is bumped up even if the entity state hasn’t been changed by the current running transaction.

A Centralized Version Control Use Case

As an exercise, we are going to emulate a centralized Version Control System, modeled as follows:

RepositoryCommitChangeOptimisticForceIncrement

The Repository is our system root entity and each state change is represented by a Commit child entity. Each Commit may contain one or more Change components, which are propagated as a single atomic Unit of Work.

The Repository version is incremented with each new Commit. For simplicity sake, we only verify the Repository entity version, although a more realistic approach would surely check each individual file version instead (to allow non-conflicting commits to proceed concurrently).

Testing time

First, we should check if the OPTIMISTIC_FORCE_INCREMENT Lock Mode suits our use case requirements:

doInTransaction(session -> {
	Repository repository = (Repository) session.get(Repository.class, 1L);
	session.buildLockRequest(new LockOptions(LockMode.OPTIMISTIC_FORCE_INCREMENT)).lock(repository);
	Commit commit = new Commit(repository);
	commit.getChanges().add(new Change("README.txt", "0a1,5..."));
	commit.getChanges().add(new Change("web.xml", "17c17..."));
	session.persist(commit);
});

This code generates the following output:

#Alice selects the Repository and locks it using an OPTIMISTIC_FORCE_INCREMENT Lock Mode
Query:{[select lockmodeop0_.id as id1_2_0_, lockmodeop0_.name as name2_2_0_, lockmodeop0_.version as version3_2_0_ from repository lockmodeop0_ where lockmodeop0_.id=?][1]} 

#Alice makes two changes and inserts a new Commit
Query:{[select lockmodeop0_.id as id1_2_0_, lockmodeop0_.name as name2_2_0_, lockmodeop0_.version as version3_2_0_ from repository lockmodeop0_ where lockmodeop0_.id=?][1]} 
Query:{[insert into commit (id, repository_id) values (default, ?)][1]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][1,0a1,5...,README.txt]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][1,17c17...,web.xml]} 

#The Repository version is bumped up
Query:{[update repository set version=? where id=? and version=?][1,1,0]} 

Our user has selected a Repository and issued a new Commit. At the end of her transaction, the Repository version is incremented as well (therefore recording the new Repository state change).

Conflict detection

In our next example, we are going to have two users (Alice and Bob) to concurrently commit changes. To avoid losing updates, both users acquire an explicit OPTIMISTIC_FORCE_INCREMENT Lock Mode.

Before Alice gets the chance to commit, Bob has just finished his transaction and incremented the Repository version. Alice transaction will be rolled back, throwing an unrecoverable StaleObjectStateException.

ExplicitLockingOptimisticForceIncrement

To emulate the conflict detection mechanism, we are going to use the following test scenario:

doInTransaction(session -> {
	Repository repository = (Repository) session.get(Repository.class, 1L);
	session.buildLockRequest(new LockOptions(LockMode.OPTIMISTIC_FORCE_INCREMENT)).lock(repository);

	executeSync(() -> {
		doInTransaction(_session -> {
			Repository _repository = (Repository) _session.get(Repository.class, 1L);
			_session.buildLockRequest(new LockOptions(LockMode.OPTIMISTIC_FORCE_INCREMENT)).lock(_repository);
			Commit _commit = new Commit(_repository);
			_commit.getChanges().add(new Change("index.html", "0a1,2..."));
			_session.persist(_commit);
		});
	});

	Commit commit = new Commit(repository);
	commit.getChanges().add(new Change("README.txt", "0a1,5..."));
	commit.getChanges().add(new Change("web.xml", "17c17..."));
	session.persist(commit);
});

The following output is generated:

#Alice selects the Repository and locks it using an OPTIMISTIC_FORCE_INCREMENT Lock Mode
Query:{[select lockmodeop0_.id as id1_2_0_, lockmodeop0_.name as name2_2_0_, lockmodeop0_.version as version3_2_0_ from repository lockmodeop0_ where lockmodeop0_.id=?][1]} 

#Bob selects the Repository and locks it using an OPTIMISTIC_FORCE_INCREMENT Lock Mode
Query:{[select lockmodeop0_.id as id1_2_0_, lockmodeop0_.name as name2_2_0_, lockmodeop0_.version as version3_2_0_ from repository lockmodeop0_ where lockmodeop0_.id=?][1]} 

#Bob makes a change and inserts a new Commit
Query:{[insert into commit (id, repository_id) values (default, ?)][1]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][1,0a1,2...,index.html]} 

#The Repository version is bumped up to version 1
Query:{[update repository set version=? where id=? and version=?][1,1,0]} 

#Alice makes two changes and inserts a new Commit
Query:{[insert into commit (id, repository_id) values (default, ?)][1]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][2,0a1,5...,README.txt]} 
Query:{[insert into commit_change (commit_id, diff, path) values (?, ?, ?)][2,17c17...,web.xml]} 

#The Repository version is bumped up to version 1 and a conflict is raised
Query:{[update repository set version=? where id=? and version=?][1,1,0]} 
INFO  [main]: c.v.h.m.l.c.LockModeOptimisticForceIncrementTest - Failure: 
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : 
[com.vladmihalcea.hibernate.masterclass.laboratory.concurrency.LockModeOptimisticForceIncrementTest$Repository#1]

This example exhibits the same behavior as the typical implicit optimistic locking mechanism. The only difference lies in the version change originator. While implicit locking only works for modifying entities, explicit locking can span to any managed entity instead (disregarding the entity state change requirement).

Conclusion

The OPTIMISTIC_FORCE_INCREMENT is therefore useful for propagating a child entity state change to an unmodified parent entity. This pattern can help us synchronize various entity types, by simply locking a common parent of theirs.

When a child entity state change has to trigger a parent entity version incrementation, the explicit OPTIMISTIC_FORCE_INCREMENT lock mode is probably what you are after.

Code available on GitHub.

If you have enjoyed reading my article and you’re looking forward to getting instant email notifications of my latest posts, consider following my blog.