Hello everyone,
I recently encountered what I think is a very surprising behavior with orphanRemoval=true and auto-flush.
Some context
An integration test failed after I tried to change the @Id @GeneratedValue of an Event entity from UUID to IDENTITY. For some reason, an entity Projection had a collection with 12 children instead of just the expected 3.
What we were doing in the transaction is some basic “event-sourcing” stuff:
- persist a new
Evententity - request the database with a HQL query for a bunch of
Evententities, including the new one.- I’ll refer to this step with (A) later.
- process these events to create an aggregate view.
- update an existing
Projectionentity using this aggregate view as the source.- This is where the
orphanRemoval=truecollection is cleared and replaced with new child entities.
- This is where the
Now, in my integration test there were 4 events to be persisted in the same transaction, so the steps from above were repeated for each event.
I initially thought the change to @GeneratedValue(strategy = IDENTITY) caused this to happen because of the early-insert behavior of the ActionQueue which maybe could have caused some child entities to be flushed earlier than before. Actually, this early-insert led to an optimization of the (A) query to not trigger an auto-flush anymore.
The test case
Please find here a minimal reproducer test case.
I dug around the codebase a little and I think what happens in the for-loop starting at l.40 is:
- second loop : the HQL query triggers a
preFlushevent, which eventually goes intoCascade#cascadeCollectionElements- two insertion events are added to the
ActionQueue(one for eachChildEntityof the 1st loop) - two deletion events are added to the queue via
deleteOrphans(one for each child created in the first transaction)
- two insertion events are added to the
- Hibernate determines that a flush isn’t actually needed because the query space is not related to the events in the
ActionQueue. - third loop : the HQL query triggers a
preFlushevent- two insertion events are added to the
ActionQueue(one for eachChildEntityof 2nd loop) - here’s where it gets interesting : no deletion events are added to the queue because
deleteOrphansget its orphans from the collection snapshot… which is only updated after a flush! So, the “snapshot orphans” still refer to the children from the first transaction, even though they’ve already been marked for removal by the previous loop.
- two insertion events are added to the
- When the transaction commits, the
ActionQueuehas 6 pending insertions and 2 pending deletions to be flushed.- Depending on the
@JoinColumnattributes, the parent may have 2 or 6 children. However, in every case, there are 6ChildEntityin the database. - Side note: this also happens if the
@OneToManyside owns the relationship.
- Depending on the
So I would be very interested to hear about your opinions on this.
I think this is a very counter-intuitive behavior, perhaps even a bug ? I was quite surprised to see that a seemingly innocuous query on some unrelated entities could disrupt the synchronization of an orphanRemoval collection and its children.
Cheers,
Maxime