Over a million developers have joined DZone.

How To Fix Optimistic Locking Race Conditions With Pessimistic Locking

· Java Zone

Check out this 8-step guide to see how you can increase your productivity by skipping slow application redeploys and by implementing application profiling, as you code! Brought to you in partnership with ZeroTurnaround.

In my previous post, I explained the benefits of using explicit optimistic locking. As we then discovered, there’s a very short time window in which a concurrent transaction can still commit a Product price change right before our current transaction gets committed.

This issue can be depicted as follows:

ExplicitLockingLockModeOptimisticRaceCondition

  • Alice fetches a Product
  • She then decides to order it
  • The Product optimistic lock is acquired
  • The Order is inserted in the current transaction database session
  • The Product version is checked by the Hibernate explicit optimistic locking routine
  • The price engine manages to commit the Product price change
  • Alice transaction is committed without realizing the Product price has just changed

Replicating the issue

So we need a way to inject the Product price change in between the optimistic lock check and the order transaction commit.

After analyzing the Hibernate source code, we discover that theSessionImpl.beforeTransactionCompletion() method is calling the current configuredInterceptor.beforeTransactionCompletion() callback, right after the internalactionQueue stage handler (where the explicit optimistic locked entity version is checked):

public void beforeTransactionCompletion(TransactionImplementor hibernateTransaction) {
    LOG.trace( "before transaction completion" );
    actionQueue.beforeTransactionCompletion();
    try {
        interceptor.beforeTransactionCompletion( hibernateTransaction );
    }
    catch (Throwable t) {
        LOG.exceptionInBeforeTransactionCompletionInterceptor( t );
    }
}   

Armed with this info, we can set-up a test to replicate our race condition:

private AtomicBoolean ready = new AtomicBoolean();
private final CountDownLatch endLatch = new CountDownLatch(1);
 
@Override
protected Interceptor interceptor() {
    return new EmptyInterceptor() {
        @Override
        public void beforeTransactionCompletion(Transaction tx) {
            if(ready.get()) {
                LOGGER.info("Overwrite product price asynchronously");
 
                executeNoWait(new Callable<Void>() {
                    @Override
                    public Void call() throws Exception {
                        Session _session = getSessionFactory().openSession();
                        _session.doWork(new Work() {
                            @Override
                            public void execute(Connection connection) throws SQLException {
                                try(PreparedStatement ps = connection.prepareStatement("UPDATE product set price = 14.49 WHERE id = 1")) {
                                    ps.executeUpdate();
                                }
                            }
                        });
                        _session.close();
                        endLatch.countDown();
                        return null;
                    }
                });
                try {
                    LOGGER.info("Wait 500 ms for lock to be acquired!");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    throw new IllegalStateException(e);
                }
            }
        }
    };
}
 
@Test
public void testExplicitOptimisticLocking() throws InterruptedException {
    try {
        doInTransaction(new TransactionCallable<Void>() {
            @Override
            public Void execute(Session session) {
                try {
                    final Product product = (Product) session.get(Product.class, 1L, new LockOptions(LockMode.OPTIMISTIC));
                    OrderLine orderLine = new OrderLine(product);
                    session.persist(orderLine);
                    lockUpgrade(session, product);
                    ready.set(true);
                } catch (Exception e) {
                    throw new IllegalStateException(e);
                }
                return null;
            }
        });
    } catch (OptimisticEntityLockException expected) {
        LOGGER.info("Failure: ", expected);
    }
    endLatch.await();
}
 
protected void lockUpgrade(Session session, Product product) {}

When running it, the test generates the following output:

#Alice selects a Product
DEBUG [main]: Query:{[select abstractlo0_.id as id1_1_0_, abstractlo0_.description as descript2_1_0_, abstractlo0_.price as price3_1_0_, abstractlo0_.version as version4_1_0_ from product abstractlo0_ where abstractlo0_.id=?][1]} 
 
#Alice inserts an OrderLine
DEBUG [main]: Query:{[insert into order_line (id, product_id, unitPrice, version) values (default, ?, ?, ?)][1,12.99,0]} 
#Alice transaction verifies the Product version
DEBUG [main]: Query:{[select version from product where id =?][1]} 
 
#The price engine thread is started
INFO  [main]: c.v.h.m.l.c.LockModeOptimisticRaceConditionTest - Overwrite product price asynchronously
#Alice thread sleeps for 500ms to give the price engine a chance to execute its transaction
INFO  [main]: c.v.h.m.l.c.LockModeOptimisticRaceConditionTest - Wait 500 ms for lock to be acquired!
 
#The price engine changes the Product price
DEBUG [pool-1-thread-1]: Query:{[UPDATE product set price = 14.49 WHERE id = 1][]} 
 
#Alice transaction is committed without realizing the Product price change
DEBUG [main]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection

So, the race condition is real. It’s up to you to decide if your current application demands stronger data integrity requirements, but as rule of thumb, better safe than sorry.

Fixing the issue

To fix this issue, we just need to add a pessimistic lock request just before ending our transactional method.

@Override
protected void lockUpgrade(Session session, Product product) {
    session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_READ)).lock(product);
}

The explicit shared lock will prevent concurrent writes on the entity we’ve previously locked optimistically. With this method, no other concurrent transaction can change the Product prior to releasing this lock (after the current transaction is committed or rolled back).

ExplicitLockingLockModeOptimisticRaceConditionFix

With the new pessimistic lock request in place, the previous test generates the following output:

#Alice selects a Product
DEBUG [main]: Query:{[select abstractlo0_.id as id1_1_0_, abstractlo0_.description as descript2_1_0_, abstractlo0_.price as price3_1_0_, abstractlo0_.version as version4_1_0_ from product abstractlo0_ where abstractlo0_.id=?][1]} 
 
#Alice inserts an OrderLine
DEBUG [main]: Query:{[insert into order_line (id, product_id, unitPrice, version) values (default, ?, ?, ?)][1,12.99,0]} 
 
#Alice applies an explicit physical lock on the Product entity
DEBUG [main]: Query:{[select id from product where id =? and version =? for update][1,0]} 
 
#Alice transaction verifies the Product version
DEBUG [main]: Query:{[select version from product where id =?][1]} 
 
#The price engine thread is started
INFO  [main]: c.v.h.m.l.c.LockModeOptimisticRaceConditionTest - Overwrite product price asynchronously
#Alice thread sleeps for 500ms to give the price engine a chance to execute its transaction
INFO  [main]: c.v.h.m.l.c.LockModeOptimisticRaceConditionTest - Wait 500 ms for lock to be acquired!
 
#The price engine cannot proceed because of the Product entity was locked exclusively, so Alice transaction is committed against the ordered Product price
DEBUG [main]: o.h.e.t.i.j.JdbcTransaction - committed JDBC Connection
 
#The physical lock is released and the price engine can change the Product price
DEBUG [pool-1-thread-1]: Query:{[UPDATE product set price = 14.49 WHERE id = 1][]} 

Even though we asked for a PESSIMISTIC_READ lock, HSQLDB can only execute a FOR UPDATE exclusive lock instead, equivalent to an explicit PESSIMISTIC_WRITElock mode.

Conclusion

If you wonder why we use both optimistic and pessimistic locking for our current transaction, you must remember that optimistic locking is the only feasible concurrency control mechanism for multi-request conversations.

In our example, The Product entity is loaded by the first request, using a read-only transaction. The Product entity has an associated version, and this read-time entity snapshot is going to be locked optimistically during the write-time transaction.

The pessimistic lock is useful only during the write-time transaction, to prevent any concurrent update from occurring after the Product entity version check. So, both the logical lock and the physical lock are cooperating for ensuring the Order price data integrity.

While I was working on this blog post, the Java Champion Markus Eisele took me an interview about the Hibernate Master Class initiative. During the interview I tried to explain the current post examples, while emphasizing the true importance of knowing your tools beyond the reference documentation.

Code available on GitHub.

The Java Zone is brought to you in partnership with ZeroTurnaround. Check out this 8-step guide to see how you can increase your productivity by skipping slow application redeploys and by implementing application profiling, as you code!

Topics:

Published at DZone with permission of Vlad Mihalcea, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

The best of DZone straight to your inbox.

SEE AN EXAMPLE
Please provide a valid email address.

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.
Subscribe

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}