Skip to content

GORM for Hibernate 7 inserts then updates new entities with pre-insert id generators (version starts at 1) #16349

Description

@jdaugherty

Summary

On GORM for Hibernate 7 (8.1.x), a newly saved entity whose identifier is generated before the insert (id generator: 'increment', 'table', and presumably any other non-identity strategy) is written with an INSERT immediately followed by an UPDATE of the same row in the first flush. The row therefore ends up at version == 1 right after save(flush: true) instead of 0, and every plain save costs two DML statements instead of one. Entities using the default identity generator are unaffected. GORM for Hibernate 5 does not have the problem for any generator.

Reproduction

@Entity
class Book {
    Long id
    Long version
    String title
    static mapping = { id generator: 'increment' }
}
def statistics = sessionFactory.statistics
statistics.statisticsEnabled = true
statistics.clear()

def book = new Book(title: 'original').save(flush: true, failOnError: true)

assert book.version == 0                    // fails: version is 1
assert statistics.entityInsertCount == 1    // passes
assert statistics.entityUpdateCount == 0    // fails: 1

Observed with Statistics on H2, Hibernate ORM 7.4.x, in a HibernateGormDatastoreSpec-style test (grails-data-hibernate7-core), against both the current 8.1.x (0980623) and a feature branch on top of it:

Entity before flush after flush version
default identity generator hasChanged() == false, insert already executed 1 insert, 0 updates 0
id generator: 'increment' hasChanged() == true, hasChanged('title') == true, insert pending 1 insert, 1 update 1
id generator: 'increment', trackChanges() called manually before the flush 1 insert, 0 updates 0
tablePerConcreteClass true hierarchies (which require a non-identity generator) as increment 1 insert, 1 update 1

The same happens with a bare session.persist(book); session.flush(), so it is not specific to the GORM save() path.

Cause

GORM's DirtyCheckable reports an instance whose change tracking has never been activated ($changedProperties == null) as changed on every property: both hasChanged() and hasChanged(name) return true until trackChanges() has been called.

GrailsEntityDirtinessStrategy (the CustomEntityDirtinessStrategy GORM registers with Hibernate) relies on tracking having been activated by the time Hibernate dirty-checks an entity at flush. Where that activation happens differs between the two modules:

  • Hibernate 5 (org.grails.orm.hibernate.support.ClosureEventTriggeringInterceptor): onSaveOrUpdate calls activateDirtyChecking(entity) when the entity is saved, i.e. before any flush, regardless of the id strategy. It also calls it in onPostInsert.
  • Hibernate 7: Hibernate 6+ removed SaveOrUpdateEvent. The Hibernate 7 interceptor implements onPersist, but that override only publishes the GORM persist event and delegates to the default listener; activateDirtyChecking is now called only from onPostInsert.

With the identity generator, Hibernate executes the insert inside persist, so onPostInsert fires immediately and the entity is tracked (and clean) by the time of the flush. With a pre-insert generator, the EntityInsertAction is queued and only executed during flush, after flushEntities has already dirty-checked the entity. At that point the instance is still untracked, GrailsEntityDirtinessStrategy.findDirty marks every attribute dirty, and Hibernate schedules an UPDATE (bumping the version) right behind the INSERT. The manual trackChanges() row in the table above confirms this is the whole story.

Impact

  • version starts at 1 for every entity with a non-identity generator; anything comparing against 0, or relying on the version to count updates, is off by one.
  • Every save of such an entity issues an extra UPDATE that rewrites all columns (and, for a lastUpdated property, stamps it at insert time).
  • Table-per-concrete-class hierarchies are always affected, since Hibernate rejects the identity generator for union subclasses.
  • Tests written on Hibernate 5 with literal version assertions fail when moved to Hibernate 7. The tablePerConcreteClass feature added to Hibernate7RefreshLockSpec in feature: enhance supported lock modes in Gorm across Hibernate 5 & 7 #16344 had to assert versions relative to the saved instance for this reason.

Suggested fix

Activate dirty checking in the Hibernate 7 interceptor at persist time, mirroring what the Hibernate 5 onSaveOrUpdate override does, e.g. call activateDirtyChecking(entity) for an initialized entity in both onPersist overloads before delegating (or, equivalently, from the persist event publication). The existing onPostInsert call can stay for entities inserted outside persist. A regression test should save an entity with id generator: 'increment' and assert version == 0 and entityUpdateCount == 0 after the flush, alongside the existing identity-generator coverage; a tablePerConcreteClass true pair covers the case users cannot avoid.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions