Unraveling Hibernate’s Dirty Checking
In software development, enhancing existing features can sometimes lead to unexpected challenges. What begins as a straightforward task may uncover twisted issues that reveal the deeper working of the tools and frameworks we rely on. My recent experience with hibernate is a perfect illustration of this phenomenon.
This write up explores a pestering issue encountered during the enhancement of our refund processing system’s payment metadata. The investigation reveals how a seemingly minor oversight in implementing equals()
and hashCode()
methods led to unexpected behavior because of Hibernate's dirty checking mechanism.
The Feature Request: Enriching Refund Metadata
The request was to enhance the payment refund system by enriching the payment metadata with detailed breakup information provided by the payment provider. The goal was to provide more granular insights into each refund, facilitating better transparency and accountability.
Description of the problem
Given the feature request which was part of Refunds system, I started implementing the changes. Given this was the Database Schema which Refund entity had;
CREATE TABLE refund (
id BIGINT,
version SMALLINT NOT NULL,
payment_meta_data TEXT,
state VARCHAR(50),
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id, created_at)
);
The initial implementation involved enhancement by extending the PaymentMetaData
class to include breakup details:
@Getter
@Setter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy.class)
public class PaymentMetaData extends MetaData {
@NonNull private String referenceId;
@NonNull private String paymentProvider;
private BreakupDetail breakupDetail; // New addition
}
// new class added
@Getter
@Setter
@Builder
@ToString
public class BreakupDetail {
private BigDecimal amount;
private String currency;
}
Well, the change seemed straight forward, and was quickly done in couple of mins. But what unfolded next took an entire day to figure it out.
Issue Detection and Analysis
Observed Behavior
Initial testing revealed that updates to PaymentMetaData
were not persisting despite successful code execution. This led to scratching brain on what the issue might be, and going back to the OG way of debugging by adding logs in the codebase, to the extent of tailing logs in MySQL server to check what request was reaching the database server.
On discovering the issue, I did a local setup, and started checking application logs one by one
INFO: updateRefundInternal state R57TBQ :: COMPLETED
INFO: Current refund: RefundEntity{id=1} with state INITIATED
INFO: Validating state transition COMPLETED
INFO: Updating refund with amount 10.0 and paymentMetaData
the logs indicated that the newly added update function in the repository was invoked, and all the necessary code path was getting executed correctly, only for database to not reflect any rows as affected rows was “0”
int affectedRow =
refundRepository.updateRefund(
refund.getId(),
refund.getCreatedAt(),
refund.getVersion(),
...);
if (affectedRow <= 0) {
throw new RefundServiceException(Errors.CONCURRENT_UPDATE_EXCEPTION);
}
In logs, I saw that hibernate was making an update call on database. But i was not sure if hibernate handled this request silently actually invoked db.
So as a next step to drill down ,I configured logging on MySQL server, and started tailing all the queries coming to MySQL
2024-12-10T09:10:10.822404Z Query SET autocommit=0
2024-12-10T09:10:11.007742Z Query update refund
set updated_at='2024-12-10 09:10:10.992919',
version=6,
payment_meta_data='{"version":"V1","data":{...}}'
where id=176721 and version=5
2024-12-10T09:10:11.029143Z Query update refund
set state='COMPLETED',
payment_meta_data='{"version":"V1","data":{...}}',
version=version+1
where id=176721 and version=5
From MySQL logs, I saw that 2 refund update calls were happening whereas in code only one update was invoked in code path.
This hinted me about hibernate’s behind the scene handling of database requests and the way it does it. The issue was Hibernate was unable to figure out hashCodeAndEqual for existing PaymentMetadata, so it tried to make one update on PaymentMetadata first, this increased the version in existing transaction, and the 2nd update failed as it was doing optimistic locking using version field as reflected in the Mysql logs.
Root Cause Analysis
Hibernate Dirty Checking Behavior
The database logs revealed a crucial insight: Hibernate was generating two separate update queries within the same transaction:
First Update: Automatically triggered by Hibernate’s dirty checking
Incremented version from 5 to 6 and modified payment_meta_data
Second Update (Our Intended Update): Failed due to version mismatch
Attempted to update with original version (5), and affected rows = 0 due to optimistic locking failure
Understanding Hibernate’s Dirty Checking
To appreciate the root cause and the resolution, it’s essential to understand how Hibernate’s dirty checking works and the role of equals()
and hashCode()
in this process.
What is Dirty Checking?
Dirty checking is Hibernate's mechanism for tracking changes to entities within a session (also known as the persistence context). When an entity is loaded from the database, Hibernate maintains a snapshot of its original state. Throughout the transaction, Hibernate monitors any modifications to the entity's properties. At the end of the transaction or at specific flush points, Hibernate compares the current state of the entity against the original snapshot to determine if any changes have been made. If discrepancies are found, Hibernate generates the necessary SQL UPDATE
statements to synchronize the in-memory state with the database.
The Role of
equals()
andhashCode()
For dirty checking to function correctly, Hibernate relies on the equals()
and hashCode()
methods of the entity classes:
equals()
Method:
- Determines if two objects are logically equivalent.
- Use in Hibernate: Compares the original state snapshot with the current state to identify changes.
- Implication: Ifequals()
is not properly overridden, Hibernate may incorrectly assess whether an entity has been modified.hashCode()
Method:
- Provides a hash representation of the object, facilitating efficient storage and retrieval in hash-based collections.
- Use in Hibernate: Assists in managing entities within collections and caches.
- Implication: An incorrecthashCode()
can lead to inconsistent behavior in collections, potentially affecting dirty checking.
As you might have understood until now, that the problem was missing equals()
and hashCode()
in the newly added BreakupDetail
class. The BreakupDetail
class was introduced without overriding equals()
and hashCode()
methods.
Well the fix was simple. Given I was already using Lombok, just adding @EqualAndHashCode
annotation fixed the issue
@Getter
@Setter
@Builder
@ToString
@EqualsAndHashCode
public class BreakupDetail {
private BigDecimal amount;
private String currency;
}
Even though the fix was a one line change, I had to scratch my head for two days, thus decided to blog my experience so that next time I stumble upon same issue at least Iwould have this as a reference to learn quickly from : p