Search code examples
javaspringhibernatejpaspring-data-jpa

How to write data into another table after @PostUpdate action using Spring Data Jpa


Please note that a similar question asked, but the answer includes Camel JPA specific implementation.

I have a two JPA entities Bo3 and Bo3Audit. In Bo3, there are some fields specified as audit fields. I don't want to take action for all the changes in Bo3, but only for the changes in those fields. When any of those fields' data is inserted (not null) or updated for a particular row, I need to insert an entry in Bo3Audit with the field values.

Bo3

@Entity // and other JPA annotations
public class Bo3 {
  // persisted fields prop1, prop2, prop3

  // prop1, prop2 are the audit fields
  @Transient@JsonIgnore
  private List<String> myAuditFields = List.of("prop1", "prop2");

  // A supplier which fetches all the getters for the audit fields
  @Transient@JsonIgnore
  Supplier<Stream<Method>> streamSupplier = () -> Arrays.stream(Bo3Audit.class.getDeclaredFields())
        .filter(f -> myAuditFields.contains(f.getName()))
        .map(f->Objects.requireNonNull(
                BeanUtils.findMethod(Bo3Audit.class, "get"+ StringUtils.capitalize(f.getName()))
        ));

}

Bo3Audit

@Entity // and other JPA annotations
public class Bo3Audit {
  // persisted fields prop1, prop2 (only Bo3's audit fields)

  @ManyToOne(lazy fetch)
  private Bo3 bo3;
}

Bo3Audit Repository

@Repository
public interface Bo3AuditRepository extends JpaRepository<Bo3Audit, Long> {
    @Query("from Bo3Audit where bo3.id = :id order by createdAt desc")
    List<Bo3Audit> findLastAudit(Long id, Pageable pageable);
}

PrePersist

The prePersist listener in Bo3 is working as expected. If either prop1 or prop2 has a non null value, it inserts a row in Bo3Audit copying those values.

  @PrePersist
  public void prePersist() {
    Bo3Audit bo3Audit = generateAudit(this); // copies prop1, prop2 and assign "this" to ManyToOne field bo3
    streamSupplier.get().map(m-> {
                try {
                    return m.invoke(bo3Audit);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    throw new RuntimeException(e);
                }
            })
            .filter(Objects::nonNull)
            .findAny().ifPresent(value -> {
                MyBeanUtils.getBean(Bo3AuditRepository.class).save(bo3Audit);
            });
  }

PostUpdate

But the postUpdate listener is failing with ConcurrentModificationException.

@PostUpdate
public void postUpdate() {
    Bo3AuditRepository repo = MyBeanUtils.getBean(Bo3AuditRepository.class);
    Bo3Audit bo3Audit = generateAudit(this); // copies prop1, prop2 and assign "this" to ManyToOne field bo3
    List<Bo3Audit> lastAudits = repo.findLastAudit(this.getId(), PageRequest.of(0,1));
    ///
    // Some logic using streamSupplier to check any one of the "prop1" and "prop2" value is changed.
    ///
    if (isChanged) {
        repo.save(bo3Audit);
    }
}

Notes:

  • If @PreUpdate is used, a StackOverFlow error is throw somewhere around line repo.findLastAudit(this.getId(), PageRequest.of(0,1));
  • If the @PostUpdate listener annotation is removed and call the postUpdate method from business logic just after bo3Repository.save(newBo3) command, this is working fine. An audit row is inserted if there is change in either "prop1" or "prop2".

But I need the listener to work. Please suggest.

Some stack trace

java.util.ConcurrentModificationException: null
    at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
    at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
    at java.base/java.util.Collections$UnmodifiableCollection$1.next(Collections.java:1054)
    at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:602)
    at org.hibernate.engine.spi.ActionQueue.lambda$executeActions$1(ActionQueue.java:478)
    at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:721)
    at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:475)
    at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:344)
    at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40)
    at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:107)
    at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1407)
    at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:489)
    at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:3303)
    at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2438)
    at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:449)
    at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:183)
    at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.access$300(JdbcResourceLocalTransactionCoordinatorImpl.java:40)
    at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:281)
    at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:101)
    at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:562)
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:743)
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:654)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:407)
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
    at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:763)
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708)
    at com.paanini.generated.app.jiffy.controller.defaultInternalServiceController$$EnhancerBySpringCGLIB$$cff4506b.UpdateBo3(<generated>)

Solution

  • The prePersist is working, but not the postUpdate. So I had a look at the stack trace again and noted that it was complaining about concurrent modification on some List. The only list in that method is List<Bo3Audit> lastAudits which I fetch to compare old and new audit specific fields.

    This was the culprit. In the Hibernate perspective, the newly created bo3Audit is a member of that list and calling save on bo3Audit would throw the concurrent modification error.

    I did the below updates to the code and it is working as expected.

    1. When the Bo3 is loaded, the corresponding Bo3Audit is generated and cached to a transient property in Bo3. The hibernate will load the data before updating. So this will work in @PostUpdate.

      @Transient@JsonIgnore private Bo3Audit lastBo3Audit;
      @PostLoad
      public void postLoad() {
          this.lastBo3Audit = this.generateAuditBo();
      }
      
    2. In @PostUpdate, the oldValues were generated from this cached property, instead of fetching this from database.

      oldValues = streamSupplier.get().map(m-> {
                      try {
                          return m.invoke(lastBo3Audit);
                      } catch (IllegalAccessException | InvocationTargetException e) {
                          throw new RuntimeException(e);
                      }
                  })
                  .filter(Objects::nonNull)
                  .toList();
      

    The queries generated:

    Hibernate: select bo3x0_.id as id1_0_0_, bo3x0_.prop1 as prop2_0_0_, bo3x0_.prop2 as prop3_0_0_, bo3x0_.prop3 as prop4_0_0_ from bo3 bo3x0_ where bo3x0_.id=?
    Hibernate: update bo3 set prop1=?, prop2=?, prop3=? where id=?
    Hibernate: insert into bo3audit (bo3_id, prop1, prop2) values (?, ?, ?)
    

    Notes:

    1. Now that I have the lastBo3Audit, the streamSupplier can be removed and compare lastBo3Audit and new bo3Audit.
    2. I need to test this, to see how it will behave in a real scenario.