StaleObjectStateException in multi-tenancy on table

Hi! I have an application with multi-tenancy at the record level.
There was a need to update the dependency version Hibernate 6.3.0.CR1 → 6.6.13.Final. I did everything according to the migration guide.
After the update, I noticed strange behavior of the application when saving the entity.

Becase entities have a large description, I will give a small example.
For example, exists two entites Shop and Product with next Java description

class Shop {
  @Id
  @Column(name = "shop_uuid")
  @Builder.Default
  private UUID shopUuid = UUID.randomUUID();

  @TenantId
  @Column(name = "tenant_code", nullable = false)
  private String tenantCode;

  @OneToMany(fetch = FetchType.LAZY)
  @JoinColumn(name = "shop_uuid", referencedColumnName = "shop_uuid")
  private List<Product> products;
}
class Product {
  @Id
  @GeneratedValue(strategy = GenerationType.UUID)
  @Column(name = "...")
  private UUID productUuid;

  @Column(name = "product_amount")
  private Integer amount;

  @ManyToOne(fetch = FetchType.EAGER)
  @JoinColumn(name = "shop_uuid", referencedColumnName = "shop_uuid", nullable = false)
  private Shop shop;
}

In addition, there are two classes that help work with multi-tenancy

public class TenantContext {
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal();

    private TenantContext() {
    }

    public static String getCurrentTenant() {
        return (String) CURRENT_TENANT.get();
    }

    public static void setCurrentTenant(String tenant) {
        CURRENT_TENANT.set(tenant);
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver, HibernatePropertiesCustomizer {

    public String resolveCurrentTenantIdentifier() {
        return <resolving from TenantContext.class>;
    }

    public boolean validateExistingCurrentSessions() {
        return false;
    }

    public void customize(Map<String, Object> hibernateProperties) {
        hibernateProperties.put("hibernate.tenant_identifier_resolver", this);
    }

    public boolean isRoot(Object tenantId) {
        ...
    }
}

Next comes a cut from the logic in the most compressed format. There are two situations that used to work, and now one of them does not.

[SUCCESS] tenantCode from 'shop' equals 'another'
...
TenantContext.setCurrentTenant("another"); 
Product product = productRepository.findByAmount(5).get();
TenantContext.setCurrentTenant("another");
productRepository.save(product);
...
[FAILED] tenantCode from 'shop' equals 'another'
...
TenantContext.setCurrentTenant("default");
Product product = productRepository.findByAmount(5).get();
TenantContext.setCurrentTenant("another");
productRepository.save(product);
...
Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): []
        at app//org.hibernate.event.internal.DefaultMergeEventListener.entityIsDetached(DefaultMergeEventListener.java:426)
        at app//org.hibernate.event.internal.DefaultMergeEventListener.merge(DefaultMergeEventListener.java:214)
        at app//org.hibernate.event.internal.DefaultMergeEventListener.doMerge(DefaultMergeEventListener.java:152)
        at app//org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:136)
        at app//org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:89)
        at app//org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127)
        at app//org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:854)
        at app//org.hibernate.internal.SessionImpl.merge(SessionImpl.java:840)
        at java.base@17.0.14/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base@17.0.14/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
        at java.base@17.0.14/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base@17.0.14/java.lang.reflect.Method.invoke(Method.java:569)
        at app//org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:320)
        at app/jdk.proxy3/jdk.proxy3.$Proxy267.merge(Unknown Source)
        at app//org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:639)
        at java.base@17.0.14/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
        at java.base@17.0.14/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
        at java.base@17.0.14/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
        at java.base@17.0.14/java.lang.reflect.Method.invoke(Method.java:569)
        at app//org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359)
        at app//org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:277)
        at app//org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170)
        at app//org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158)
        at app//org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:515)
        at app//org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:284)
        at app//org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:731)
        at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at app//org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:174)
        at app//org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:149)
        at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at app//org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:69)
        at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at app//org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:380)
        at app//org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119)
        at app//org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at app//org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138)
        ... 19 more

Are you invoking this code in a transaction? Also, what’s your goal with this code?
I don’t know what Spring Data does these days, so if you seek further help, you will have to ask the question without the Spring parts, but know that there is no need to EntityManager#merge an entity in a transaction that you looked up with e.g. EntityManager#createQuery, because entities are “managed” in this stateful EntityManager. This means that dirty changes are flushed automatically just before committing a transaction.

Not in transaction. Unfortunately, Spring it’s part of my context and I don’t use EntityManager directly.
The point is that the error started to be thrown after migration to Hibernate 6.6. On later versions everything is ok

This is almost certainly a result of this change:

which was requested by an extremely long list of users.

Okay, I get it, does this mean that this logic won’t work anymore and I can’t update an entity in a tenant other than the one where the find() operation was performed?
Reason of this it’s relation and EAGER fetching, If you set lazy-loading, everything will work.

You could use StatelessSession I suppose.

Or you could do this kind of operation as the root temamt.

1 Like

So you have a detached entity, belonging to tenant “default” and you want to be able to update that in the context of the tenant “another”. That sounds like you’re trying to circumvent the security/isolation that Hibernate ORM provides you with its multi-tenancy feature.
You can do that by switching to the root tenant like Gavin suggested, but really, why would you want to allow that?