I am working on an application consisting in a high-throughput entity, where I need to mitigate race conditions, and some unfrequently-changed domain entities. To achieve this, we added JPA optimistic locking to the existing application.
Example:
@Entity
public class Payment {
@Id
protected Long id;
@Version
protected Long version;
protected String lastStatus; //the one I want to protect from concurrent processes
@ManyToOne
protected Channel inputChannel;
//Other columns
}
@Entity
public class Channel {
@Id
protected Long id;
// Not changing frequently (ever...), no version attribute here
}
Use case for the optimistic lock is simple: when both the response of a remote system and an async timeout occur at the same time on multiple nodes/threads, only one transaction wins, without the burded of pessimistic locking. Application's design pattern is Controller(Request)-Manager(DTO)-Repository(Entity).
However, I do have a general-purpose update method that can update virtually every field in the entity, in particular the relationship to the Channel
. That is not supposed, due to functional constraints, to have problems with race conditions.
@Transactional
public class PaymentManager {
public void update(PaymentDto dto){
var payment = repository.findById(dto.getId()).orElseThrow();
// perform all updates
// this INCLUDES changing the channel
if (dto.getInputChannel() != null && dto.getInputChannel().getId() != null) {
payment.setInputChannel(channelRepository.getReferenceById(dto.getInputChannel().getId()));
}
repository.save(payment);
}
@Retryable
public boolean upgradeStatus(Long paymentId, Status expectedStatus, Status newStatus) {
var payment = repository.findById(dto.getId()).orElseThrow();
if (!expectedStatus.name().equals(payment.getStatus()) {
return false;
}
payment.setStatus(newStatus.name());
repository.save(payment);
return true;
}
}
public class PaymentRepository extends JpaRepository.... {
@Lock(LockMode.OPTIMISTIC)
Optional<Payment> findById(Long id);
}
So, to make things simpler to me (or to admit that I am lazy) I made the entire findById subject to optimistic lock.
But now the problem is that, apparently, Hibernate attempts to perform the optimistic lock check on the Channel entity too
org.springframework.messaging.MessageHandlingException: error occurred during processing message in 'MethodInvokingMessageProcessor' [org.springframework.integration.handler.MethodInvokingMessageProcessor@6e9bf992]
at org.springframework.integration.support.utils.IntegrationUtils.wrapInHandlingExceptionIfNecessary(IntegrationUtils.java:191)
at org.springframework.integration.handler.MethodInvokingMessageProcessor.processMessage(MethodInvokingMessageProcessor.java:117)
at org.springframework.integration.handler.ServiceActivatingHandler.handleRequestMessage(ServiceActivatingHandler.java:93)
[omitted]
org.springframework.messaging.MessageHandlingException: error occurred during processing message in 'MethodInvokingMessageProcessor' [org.springframework.integration.handler.MethodInvokingMessageProcessor@6e9bf992]
at org.springframework.integration.support.utils.IntegrationUtils.wrapInHandlingExceptionIfNecessary(IntegrationUtils.java:191)
at org.springframework.integration.handler.MethodInvokingMessageProcessor.processMessage(MethodInvokingMessageProcessor.java:117)
at org.springframework.integration.handler.ServiceActivatingHandler.handleRequestMessage(ServiceActivatingHandler.java:93)
[omitted]
... 401 common frames omitted
Caused by: org.hibernate.HibernateException: Unable to perform beforeTransactionCompletion callback: Cannot invoke "Object.equals(Object)" because the return value of "org.hibernate.engine.spi.EntityEntry.getVersion()" is null
at org.hibernate.engine.spi.ActionQueue$BeforeTransactionCompletionProcessQueue.beforeTransactionCompletion(ActionQueue.java:1022)
at org.hibernate.engine.spi.ActionQueue.beforeTransactionCompletion(ActionQueue.java:546)
at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:1982)
at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:439)
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:169)
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:267)
at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:101)
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:562)
... 429 common frames omitted
Caused by: java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because the return value of "org.hibernate.engine.spi.EntityEntry.getVersion()" is null
at org.hibernate.action.internal.EntityVerifyVersionProcess.doBeforeTransactionCompletion(EntityVerifyVersionProcess.java:41)
at org.hibernate.engine.spi.ActionQueue$BeforeTransactionCompletionProcessQueue.beforeTransactionCompletion(ActionQueue.java:1016)
... 436 common frames omitted
Debugging into the Hibernate code, it seems that the EntityVerifyVersionProcess is added to the pre-commit action list both for Payment and Channel at the time the findById
is invoked, probably because of the annotation on the method.
Hibernate doesn't seem to guess (should it?) that I am not interested in tracking the version of the Channel, because I am not cascading any change in my application. Channel is a mostly-immutable entity. Mostly because it could change, but the update method never changes the content of the Channel, ever.
So, my question(s) are:
[UPDATE]
After experimenting, this is NOT related to Channel
update. The mere presence of the Channel
object in the Payment
triggers the issue
Eventually, removing the hint @Lock
worked fine.
I could not reproduce the problem in a POC, meaning that the complexity of the structure of my entities has a role in the issue, not worth investing too much time