Preface
This article considers a web application based on the following software versions:
- Spring Boot version 2.6.8
- Hibernate 5.6.9.FINAL (as a managed dependency of Spring Boot)
- Java 11
- MySQL 5.7 (for server/application use)
- H2 (for integration tests)
The goal: To upgrade the web application to Java 17 (the new LTS version).
Problem
Upgrading a Java 11 based Spring Boot web app to 17 was fairly smooth until I opened a PR and the code was built using CI hosted by Bitbucket Pipelines.
Prior to opening the PR I had built and ran all unit and integration tests locally on my MacBook Pro. I also ran the web app and did some user acceptance testing ensuring the app actually works.
So what was the problem when building and running the integration tests on the CI machines?
The Spring Boot JPA was throwing OptimisticLockingFailureExceptions but the cause was the internal Hibernate StaleObjectStateException.
I was struck by the oddity of this outcome since locally all tests passed. And, I ran the application as well and everything worked just fine. So why were the integration tests failing in the CI build (Bitbucket Pipeline)?
Guessing At the Answer
You would be right if you guessed that the problem might be related to the environment. I too realized that my MacBook laptop is not the same as running the build in the CI environment. But I was surprised to learn exactly what about the different environments was actually the source of the problem.
I observed that integration tests which were performing inserts and updates to rows in our H2 database (used for integration tests) were indeed failing the @version check performed by Hibernate. But why? I decided to Google and found the below post on a Hibernate forum:
Java 17 @version and nanoseconds truncation
Here is the bug that was opened and fixed by Hibernate
https://hibernate.atlassian.net/browse/HHH-15135
OK. So the forum and bug ticket seemed very promising as to the source of the problem, but why did it work on my machine, and not the CI? Why did the web app work when deployed too?
Further Research and Understanding
I thought I had read all the cool new features that were releases as part of Java 17 LTS. I don’t recall reading anything about nanosecond precision! Apparently, the Java team introduced nanosecond precision to their system clock in JDK 15. Of course, Java 17 includes these changes.
(See below the links to the original bug/ticket, the pull request with source changes, and the email detailing the enhancement)
Enhance the system clock to nanosecond precision
Very interesting, yes?
Making Sense of It All
An example entity had the following @version property:
@Version
@Column(columnDefinition = "DATETIME(6)")
private Instant version;
This entity property indicates that the field named version
is a Java Instant and will be used for versioning database rows. The @Column annotation with the columnDefinition
property is going to use DDL to create this table column as a datetime with precision of 6. But didn’t we learn that Java ≥ 15 now uses nanosecond precision for time? I immediately checked docs for MySQL and H2. MySQL datetime columns only allow a maximum precision of 6. But, H2 allows a maximum of 9! Furthermore, H2 doesn’t have a data type known as datetime, so properties with this type will be coerced into timestamps. It would seem like all this information is beginning to make sense, but still, how did the integration tests pass on my MacBook but not CI?
The answer to that has to do with Java supporting the time precision that is allowed by the OS libraries it has access to. So, if you are running Java on an Apple Mac, the highest precision will be 6. But running Java on a Linux based machine will support a time precision of nanoseconds.
Let’s recap:
- Java 15 and onward, uses the precision supported by the OS. I didn’t have any errors on my Apple MacBook Pro because it doesn’t support nanosecond precision. Linux however, DOES support nanosecond precision.
- MySQL only stores datetimes with microsecond precision.
- H2 coerces datetime types into timestamps and fully supports up to 9 digits of precision i.e. nanoseconds.
- Datetime objects created with Java and the underlying OS with nanosecond precision will be chopped off to microseconds and thus any @version comparisons within Hibernate will result in OptimisticLockExceptions.
But ***WHY*** does it work with MySQL (server/application use) and not H2 (integration tests only on OS that support nanosecond precision)?
The answer has to do with how each of the databases differs in the handling of the datetime data types with respect to Hibernate. It should be obvious that if the system, and thus Java, support nanoseconds, but the database data column is defined with datetime(6), then rounding must occur. Apparently, Hibernate in the generation of the SQL queries will rely on the specific database implementation for handling the data types. Read here for more details:
Explain why datetime types for @Version get OLE using >= Java 15
H2 is handling the rounding of nanosecond Instants and what they are compared to within Hibernate for the SQL update query differently than MySQL and thus using a Java Instant with nanosecond precision stored as an @version property with a lower precision of microseconds does not work.
The Solution
Make the columnDefintion
datetime precision equal to nanoseconds.
@Version
@Column(columnDefinition = "DATETIME(9)")
private Instant version;
This solves the problem with H2 when running integration tests on JVMs running on OSs that support that level of precision. Furthermore, the highest level of precision allowed on MySQL is microseconds so this setting will result in a datetime with the default precision of 6.
Alternate Solutions (considered but not feasible)
-
Upgrade Hibernate
You will have noticed that the issue of handling precision with Java datetime objects was resolved if you read the link to the Hibernate bug ticket earlier. However, the bug was fixed in version 6. So why not upgrade to the Hibernate library version that contains this bug fix? Well, in Spring Boot 2.6.8, Hibernate 6 is prohibited. This has to do with an even larger change that occurred within Jakarata, the newest Java EE specification. The namespaces were changed and thus running an application on Spring Boot 2.6.8 does not allow using Hibernate 6.
-
Upgrade Spring Boot
Another approach considered was to update Spring Boot itself in order to get Hibernate 6. This proved unfeasible because I would have to update the web app to Spring Boot 3 and this in itself will be a rather non-simple migration.
-
Don’t use H2 for integration tests
Yet another approach was to NOT use H2 for the integration tests. This too was not ideal (at the time) since the integration test is large and varied. H2 provides speed as it is in-memory (we don’t need to save the data). Our original goal was to avoid starting up separate database processes and extending the time that it would take to run tests as part of our continuous integration pipeline.
Since solving this problem I have discovered something known as testcontainers and will be exploring that as an alternative to using H2 in-memory. My work on testcontainers will likely result in another article once I figure it out and get it working.
Top comments (1)
Hi there, we encourage authors to share their entire posts here on DEV, rather than mostly pointing to an external link. Doing so helps ensure that readers don’t have to jump around to too many different pages, and it helps focus the conversation right here in the comments section.
If you choose to do so, you also have the option to add a canonical URL directly to your post.