Recently I faced an issue regarding updating entities from a @Scheduled
method where it would fail with the exception org.hibernate.TransientPropertyValueException: object references an unsaved transient instance
even though it would work seamless when invoked from a @RestController
method. This is the relevant example:
The offending method (other parts of the class omitted for brevity):
@Service
public class AnonymizationService
{
private final ItemRepository itemRepository;
public Result anonymizeItemsOlderThan(int days) {
List<Item> data = itemRepository.findAllByCreatedDateBeforeAndAnonymizationDateIsNull(Instant.now().minus(days, ChronoUnit.DAYS));
List<String> itemsAnonymized = new ArrayList<>(data.size());
data.forEach(item -> itemsAnonymized.add(itemRepository.save(item.anonymize()).getRequestId()));
return Result.builder().anonymizedItems(itemsAnonymized).build();
}
}
The @RestController
caller (again most stuff omitted):
@RestController
public class DataAnonymizationAPI
{
private final AnonymizationService anonymizationService;
@PutMapping(path = "${datadeletion.path:/anonymize}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Result> anonymizeAll(@Valid DataDeletionRules dataDeletionRules) {
return ResponseEntity.ok(anonymizationService.anonymizeItemsOlderThan(dataDeletionRules.getMinimunAge()));
}
}
Again, this works just fine when used like above. The problem happens when AnonymizationService#anonymizeItemsOlderThan()
is instead invoked from the following @Scheduled
method:
@Component
public class DataDeletionTasks
{
private final AnonymizationService anonymizationService;
private final DataAnonymizationProperties properties;
@Scheduled(cron = "${datadeletion.anonymization.schedule}")
public void anonymizeItemsPeriodically() {
anonymizationService.anonymizeItemsOlderThan(properties.getAnonymization().getMinAge());
}
}
In this case it fails with the exception mentioned above (org.hibernate.TransientPropertyValueException
).
Upon changing the log level to DEBUG and carefully analyzing it, nothing unexpected happens:
- When the method is invoked from the
@RestController
an existingEntityManager
is used and a transaction created:
o.s.orm.jpa.JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(1702787226<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.saveAndFlush]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
- When the method is invoked from the
@Scheduled
method a newEntityManager
is created:
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.saveAndFlush]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
o.s.orm.jpa.JpaTransactionManager : Opened new EntityManager [SessionImpl(644498403<open>)] for JPA transaction
Naturally, my instinct was to add @Transactional
to the Anonymization#anonymizeItemsOlderThan()
method which immediately solved it, but why?
Why does it work in one case and not in the other? Why does the saveAndFlush()
must be performed using the same EntityManager
used to retrieve the entity in the first place?
This situation made me think my knowledge is flawed on a very basic level, but somehow couldn't find a clear explanation to it. In any case feel free to point me towards relevant literature that might help me.