8

I want to publish an event if and only if there were changes to the DB. I'm running under @Transaction is Spring context and I come up with this check:

    Session session = entityManager.unwrap(Session.class);
    session.isDirty();

That seems to fail for new (Transient) objects:

@Transactional
public Entity save(Entity newEntity) {
    Entity entity = entityRepository.save(newEntity);
    Session session = entityManager.unwrap(Session.class);
    session.isDirty(); // <-- returns `false` ):
    return entity;
}

Based on the answer here https://stackoverflow.com/a/5268617/672689 I would expect it to work and return true.

What am I missing?

UPDATE
Considering @fladdimir answer, although this function is called in a transaction context, I did add the @Transactional (from org.springframework.transaction.annotation) on the function. but I still encounter the same behaviour. The isDirty is returning false.

Moreover, as expected, the new entity doesn't shows on the DB while the program is hold on breakpoint at the line of the session.isDirty().

UPDATE_2
I also tried to change the session flush modes before calling the repo save, also without any effect:

    session.setFlushMode(FlushModeType.COMMIT);
    session.setHibernateFlushMode(FlushMode.MANUAL);
Roee Gavirel
  • 18,955
  • 12
  • 67
  • 94
  • What hibernate version do you use? Did you try it with plain hibernate app? – SternK May 20 '21 at 11:42
  • Using org.springframework.boot:spring-boot-starter-data-jpa version 2.3.3.RELEASE – Roee Gavirel May 20 '21 at 11:48
  • 2
    If you look into the implementation of that method in `SessionImpl` you should see that insertions are covered, so maybe your inserts are flushed already? – Christian Beikov May 20 '21 at 14:19
  • @Christian Beikov - I know, that's what drives me crazy for the last week... – Roee Gavirel May 23 '21 at 05:35
  • where does newEntity comes from in the instruction `entityRepository.save(newEntity)` ? – tremendous7 May 23 '21 at 16:02
  • is your save method invoked through a business object? can you confirm this is not a self invocation? Spring can not intercept self invocations – tremendous7 May 23 '21 at 16:04
  • @tremendous7 - (A) the new entity is a `new Entity()` filled with data (I had a type in the original question, the new entity is the input of the function) (B) This is running in Spring context in a `@Component` business object. – Roee Gavirel May 24 '21 at 07:24
  • could you also say if the save method is overriding a method of an interface? are you invoking the save method on a spring @Component? or is it internally invoked by some other method that is not annotated @Transactional. I am asking just to be sure that spring was able to intercept your save call and decorate it within a transaction – tremendous7 May 24 '21 at 11:17

3 Answers3

5

First of all, Session.isDirty() has a different meaning than what I understood. It tells if the current session is holding in memory queries which still haven't been sent to the DB. While I thought it tells if the transaction have changing queries. When saving a new entity, even in transaction, the insert query must be sent to the DB in order to get the new entity id, therefore the isDirty() will always be false after it.

So I ended up creating a class to extend SessionImpl and hold the change status for the session, updating it on persist and merge calls (the functions hibernate is using)

So this is the class I wrote:

import org.hibernate.HibernateException;
import org.hibernate.internal.SessionCreationOptions;
import org.hibernate.internal.SessionFactoryImpl;
import org.hibernate.internal.SessionImpl;

public class CustomSession extends SessionImpl {

    private boolean changed;

    public CustomSession(SessionFactoryImpl factory, SessionCreationOptions options) {
        super(factory, options);
        changed = false;
    }

    @Override
    public void persist(Object object) throws HibernateException {
        super.persist(object);
        changed = true;
    }

    @Override
    public void flush() throws HibernateException {
        changed = changed || isDirty();
        super.flush();        
    }

    public boolean isChanged() {
        return changed || isDirty();
    }
}

In order to use it I had to:

  • extend SessionFactoryImpl.SessionBuilderImpl to override the openSession function and return my CustomSession
  • extend SessionFactoryImpl to override the withOptions function to return the extended SessionFactoryImpl.SessionBuilderImpl
  • extend AbstractDelegatingSessionFactoryBuilderImplementor to override the build function to return the extended SessionFactoryImpl
  • implement SessionFactoryBuilderFactory to implement getSessionFactoryBuilder to return the extended AbstractDelegatingSessionFactoryBuilderImplementor
  • add org.hibernate.boot.spi.SessionFactoryBuilderFactory file under META-INF/services with value of my SessionFactoryBuilderFactory implementation full class name (for the spring to be aware of it).

UPDATE
There was a bug with capturing the "merge" calls (as tremendous7 comment), so I end up capturing the isDirty state before any flush, and also checking it once more when checking isChanged()

Roee Gavirel
  • 18,955
  • 12
  • 67
  • 94
  • 1
    one additional remark: the insertion is only flushed to determine the ID value when using the `IDENTITY` ID generation strategy, using e.g. a `SEQUENCE` does not trigger an immediate insertion on `save` (leaving the session dirty) – fladdimir May 26 '21 at 21:40
  • 1
    if one invokes session.merge without having edited any field of the entity, would you say that session.isDirty is true? – tremendous7 May 29 '21 at 00:49
  • @tremendous7 - true, fixed the code. thanks! – Roee Gavirel May 30 '21 at 09:00
2

The following is a different way you might be able to leverage to track dirtiness.

Though architecturally different than your sample code, it may be more to the point of your actual goal (I want to publish an event if and only if there were changes to the DB).

Maybe you could use an Interceptor listener to let the entity manager do the heavy lifting and just TELL you what's dirty. Then you only have to react to it, instead of prod it to sort out what's dirty in the first place.

Take a look at this article: https://www.baeldung.com/hibernate-entity-lifecycle

It has a lot of test cases that basically check for dirtiness of objects being saved in various contexts and then it relies on a piece of code called the DirtyDataInspector that effectively listens to any items that are flagged dirty on flush and then just remembers them (i.e. keeps them in a list) so the unit test cases can assert that the things that SHOULD have been dirty were actually flushed as dirty.

The dirty data inspector code is on their github. Here's the direct link for ease of access.

Here is the code where the interceptor is applied to the factory so it can be effective. You might need to write this up in your injection framework accordingly.

The code for the Interceptor it is based on has a TON of lifecycle methods you can probably exploit to get the perfect behavior for "do this if there was actually a dirty save that occured".

You can see the full docs of it here.

Atmas
  • 2,389
  • 5
  • 13
1

We do not know your complete setup, but as @Christian Beikov suggested in the comment, is it possible that the insertion was already flushed before you call isDirty()?

This would happen when you called repository.save(newEntity) without a running transaction, since the SimpleJpaRepository's save method is annotated itself with @Transactional:

    @Transactional
    @Override
    public <S extends T> S save(S entity) {
        ...
    }

This will wrap the call in a new transaction if none is already active, and flush the insertion to the DB at the end of the transaction just before the method returns.

You might choose to annotate the method where you call save and isDirty with @Transactional, so that the transaction is created when your method is called, and propagated to the repository call. This way the transaction would not be committed when the save returns, and the session would still be dirty.


(edit, just for completeness: in case of using an identity ID generation strategy, the insertion of newly created entity is flushed during a repository's save call to generate the ID, before the running transaction is committed)

fladdimir
  • 1,230
  • 1
  • 5
  • 12
  • Although this function is called in a transaction context, I did try adding the @Transactional (from org.springframework.transaction.annotation) on the function. but I still encounter the same behaviour. The isDirty is returning false. ): – Roee Gavirel May 23 '21 at 08:02
  • just tried to reproduce the issue with a minimal demo project and [this integration test](https://github.com/fladdimir/so-jpa/blob/transaction-test/src/test/java/org/demo/TransactionTest.java) - can you spot differences, or provide some more details on your setup? (and as @tremendous suggested above, `@Transactional` only works under specific conditions, but I guess you already know about that? https://blog.staynoob.cn/post/2019/02/common-pitfalls-of-declarative-transaction-management-in-spring/ ) – fladdimir May 24 '21 at 11:41