We're working on a headless CMS, and we have a huge system with some objects we call EntityItems/ContentItems
. These are basically entities in the database that can be modeled by the users. They have a JSON
-style approach where these models can contain numbers, strings, and so on. This means that these items are at the core of our system and in big projects can be used quite a bit.
@Entity
@Getter
@Setter
@EqualsAndHashCode(callSuper = true, exclude = {"children", "parent"})
@NoArgsConstructor
@Table(indexes = {
@Index(name = "entityitem_objectState", columnList = "objectState"),
@Index(name = "entityitem_appscope", columnList = "appScope"),
@Index(name = "entityitem_type_id", columnList = "type_id")})
public class EntityItem extends AbstractContentItem {
private static final long serialVersionUID = 1L;
@ManyToOne(fetch = FetchType.EAGER)
private EntityItemType type;
....
@Convert(converter = ObjectMapToJsonConverter.class)
@Column(name = "itemProperties", columnDefinition = "jsonb")
private Map<String, String> itemPropertiesJson;
}
We also have an event based system, where we offer the user to react to deleting, creating and updating such EntityItems/ContentItems
. On one of these events we have a property called changedProperties
that logs what was changed in this event.
public class ContentItemChangedEvent {
private final long itemId;
private final List<String> changedProperties;
}
Finally, we have an EntityItemServiceImpl
which works with such EntityItems
. This has a container managed Entity Manager and a @Transactional
save method.
@Service
@Log4j
@Validated
public class EntityItemServiceImpl {
@Autowired
private EntityItemRepository entityItemRepository;
@Autowired
@Qualifier("defaultEntityManager")
private EntityManager entityManager;
//...
}
The entityItemRepository
is a basic Jpa/Crud repository.
public interface EntityItemRepository extends CrudRepository<EntityItem, Long>, JpaRepository<EntityItem, Long>, JpaSpecificationExecutor<EntityItem> {
....
}
When the save
method is called, we want to receive the item, fetch its JSON properties and compare them with what currently is uncommitted in the database.
@Transactional
public synchronized EntityItem save(EntityItem item, ...) {
...
if (item.getId() != null) {
var oldItem = this.entityItemRepository.findById(item.getId()).orElse(null);
var changedProperties = this.getChangedProperties(item, oldItem);
}
...
this.entityItemRepository.save(item);
...
}
Now for the issue:
When saving the item sometimes, it is already managed by the EntityManager
, due to it being in the same transaction, thus the first level cache kicks in and always returns the same instance of the item. In fewer words, when calling this.entityItemRepository.findById(item.getId())
the same instance as the one passed as parameter is given (due to the 1st level cache, as mentioned above).
In order to fix this we have tried the following, we have introduced another method called fetchOldVersionOfItem(EntityItem item)
and the method now looks like :
@Transactional
public synchronized EntityItem save(EntityItem item, ...) {
...
if (item.getId() != null) {
var oldItem = this.entityItemRepository.fetchOldVersionOfItem(item).orElse(null);
var changedProperties = this.getChangedProperties(item, oldItem);
}
...
this.entityItemRepository.save(item);
...
}
We have tried the following implementations but all have failed for a reason or another: 1)
private Optional<EntityItem> fetchOldVersionOfItem(EntityItem item) {
this.entityManager.clear();
var oldItemOpt = this.findById(item.getId());
this.entityManager.clear();
return oldItemOpt;
}
Here, two saves were happening very quickly (as this is our core feature in the CMS) and was resulting in a row was updated or deleted
error. Replacing this code with the usual findById
no longer yielded this error.
2)
private Optional<EntityItem> fetchOldVersionOfItem(EntityItem item) {
if (this.entityManager.contains(item)) {
this.entityManager.detach(item);
var oldItemOpt = this.findById(item.getId());
oldItemOpt.ifPresent(oldItem -> this.entityManager.detach(oldItem));
this.entityManager.merge(item);
return oldItemOpt;
}
return findById(item.getId());
}
This code was producing an Optimistic Lock Exception, due to some succession of saves. Removing it no longer produced the error.
3)
private Optional<EntityItem> fetchOldVersionOfItem(EntityItem item) {
if (this.entityManager.contains(item)) {
this.entityManager.detach(item);
var oldItemOpt = this.findById(item.getId());
oldItemOpt.ifPresent(oldItem -> this.entityManager.detach(oldItem));
this.entityManager.merge(item);
return oldItemOpt;
}
var oldItemOpt = findById(item.getId());
oldItemOpt.ifPresent(oldItem -> this.entityManager.detach(oldItem));
return oldItemOpt;
}
Here it had the same issue as the above one.
Now for the question: What did we do wrong with these two solutions and what is the best way to do something like this?
Note that we tested different variants of the given solutions (introducing and removing .contains
checks and etc.) and the results were the same. Also, this functionality of the system will be under a lot of stress as basically everything that happens in our projects is somehow related to saving the items somehow.
Thanks for your time!
CodePudding user response:
Saving the old state in a transient field (which is not managed by jpa) would probably be a good way of realizing this functionality, as it also avoids the need for an additional DB roundtrip.
You could e.g. clone the original map content (not only the reference) in a '@PostLoad' callback, similar to what is done here for the complete entity: Getting object field previous value hibernate JPA