We're currently migrating a Spring Boot 2 application to Spring Boot 3 (3.0.2
) which includes moving to Hibernate 6 (6.1.6
).
All Timestamps are normalized to UTC and saved to the underlying OracleDB using columns of the TIMESTAMP data type (without timezones). To make Hibernate use UTC we set the jpa.properties.hibernate.jdbc.time_zone
configuration property to true
within our application.yaml
.
After moving to Spring Boot 3 and Hibernate 6 (which introduces a more specific configuration for timestamp conversion) the migration of timestamps doesn't work as expected anymore:
When looking up entities by timestamp attributes, they won't be found.
A short example:
Saving an entity with a java.time.Instant
timestamp of 2023-12-13T00:00:00Z
to the database creates a new record as expected.
However, when trying to retrieve the record using the same timestamp the data repository will return an empty result. It therefore seems that the timestamp conversion differs between saving and querying.
We tried to adjust this behaviour using the configuration property spring.jpa.properties.hibernate.timezone.default_storage
but no matter what value we set, the behaviour kept being the same. We also tried different combinations of the jdbc.time_zone
and timezone.default_storage
config properties without recognizing any effects on the application's behaviour. We initially thought that the properties for whatever reason wouldn't be applied, but assigning invalid values raised exceptions on application startup.
The same code works like a charm when using a H2 database.
The relevant part of the application.yaml
we're using is as follows:
spring:
datasource:
driverClassName: oracle.jdbc.OracleDriver
url: jdbc:oracle:thin:@localhost:1521:xe
username: [dbuser]
password: [dbpassword]
type: org.springframework.jdbc.datasource.SimpleDriverDataSource
jpa:
show-sql: false
generate-ddl: true
hibernate.ddl-auto: none
task:
scheduling:
pool:
size: 10
properties:
hibernate:
jdbc:
time_zone: UTC
timezone:
default_storage: NORMALIZE_UTC
An example entity:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
@Entity
@Table(name ="entity")
public class DemoEntity {
@Id
@Column(name = "`id`")
UUID id;
@Column(name = "`demo_timestamp`" ,columnDefinition = "TIMESTAMP")
private Instant timestamp;
public DemoEntity() {
this.id = UUID.randomUUID();
}
}
The repository:
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@Repository
public interface EntityRepository extends JpaRepository<DemoEntity, UUID>, JpaSpecificationExecutor<DemoEntity> {
Optional<DemoEntity> findAllByTimestamp(Instant timestamp);
}
The service showcasing the observed behaviour:
import java.time.Instant;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Slf4j
public class EntityService {
private final EntityRepository repository;
@Autowired
public EntityService(EntityRepository repository) {
this.repository = repository;
}
@EventListener(ContextRefreshedEvent.class)
@Transactional
public void init() {
Instant timestamp = Instant.parse("2022-12-31T23:00:00Z");
Optional<DemoEntity> findResult = repository.findAllByTimestamp(timestamp);
if(findResult.isPresent()) {
log.info("Entity was found for timestamp {}", timestamp);
return;
}
log.info("No entity was found for timestamp {}, creating one", timestamp);
DemoEntity demoEntity = new DemoEntity();
demoEntity.setTimestamp(timestamp);
this.repository.save(demoEntity);
}
}
The service keeps creating new records with correct timestamps since it fails to look up the persisted timestamps:
2023-01-26T07:20:47.986+01:00 INFO 1274 --- [ restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2023-01-26T07:20:48.105+01:00 DEBUG 1274 --- [ restartedMain] org.hibernate.SQL : select d1_0."id",d1_0."demo_timestamp" from entity d1_0 where d1_0."demo_timestamp"=?
2023-01-26T07:20:48.106+01:00 TRACE 1274 --- [ restartedMain] org.hibernate.orm.jdbc.bind : binding parameter [1] as [TIMESTAMP_UTC] - [2022-12-31T23:00:00Z]
2023-01-26T07:20:48.130+01:00 INFO 1274 --- [ restartedMain] com.example.demo.EntityService : No entity was found for timestamp 2022-12-31T23:00:00Z, creating one
2023-01-26T07:20:48.138+01:00 DEBUG 1274 --- [ restartedMain] org.hibernate.SQL : select d1_0."id",d1_0."demo_timestamp" from entity d1_0 where d1_0."id"=?
2023-01-26T07:20:48.138+01:00 TRACE 1274 --- [ restartedMain] org.hibernate.orm.jdbc.bind : binding parameter [1] as [BINARY] - [1ccd9b88-4d18-416a-938d-d8c3fb6dac7d]
2023-01-26T07:20:48.150+01:00 DEBUG 1274 --- [ restartedMain] org.hibernate.SQL : insert into entity ("demo_timestamp", "id") values (?, ?)
2023-01-26T07:20:48.150+01:00 TRACE 1274 --- [ restartedMain] org.hibernate.orm.jdbc.bind : binding parameter [1] as [TIMESTAMP_UTC] - [2022-12-31T23:00:00Z]
2023-01-26T07:20:48.150+01:00 TRACE 1274 --- [ restartedMain] org.hibernate.orm.jdbc.bind : binding parameter [2] as [BINARY] - [1ccd9b88-4d18-416a-938d-d8c3fb6dac7d]
We also found, that looking up the entities as if they were in fact using not UTC but our local timezone CET, i.e. UTC+1, delivered the expected results. More specifically, looking up records for an Instant of 2022-31-12T22:00:00Z
returned the entities having a timestamp of 2022-31-12T23:00:00Z
. Moreover everything works fine when setting the JVM timezone using TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
or -Duser.timezone=UTC
.
It seems that UTC timestamps represented as Instants are actually converted to local time when being queried but are handled properly when being written to the database.
Do we miss anything in our configuration or is there a bug in Spring/Hibernate that causes this odd behaviour?
As far as I know the default behaviour of Hibernate 6 should match the one provided by version 5 so that no configuration changes should be required at all to preserve the previous behaviour.