Enterprise Java

How to fix optimistic locking race conditions with pessimistic locking

Recap

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 the SessionImpl.beforeTransactionCompletion() method is calling the current configured Interceptor.beforeTransactionCompletion() callback, right after the internal actionQueue 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_WRITE lock 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.

Vlad Mihalcea

Vlad Mihalcea is a software architect passionate about software integration, high scalability and concurrency challenges.
Subscribe
Notify of
guest

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

0 Comments
Inline Feedbacks
View all comments
Back to top button