Trying to understand StaleObjectStateException after upgrade to 6.6

Hey everyone,

I am running an application that has been using Hibernate since 2005 or so. A few weeks ago I decided to get the dreaded criteria API migration behind me and went straight from 5.6 to 6.6. Last week I was ready to start initial load tests with real’ish data and immediately saw the logs littered with StaleObjectStateExceptions. I tried a whole lot of things to resolve the issue. And I got the number of exceptions down quite a bit. But since the code/logic as such hasn’t changed - short of migrating some queries to the new criteria API - I am trying to understand where this problem is coming from.

Here’s a rough outline of what my code does, logically:

  • The code does scheduling for aircraft.
  • For a given aircraft, belonging to a given company, it loads all existing flights as well as the desired flight plan.
  • It generates all the flights missing in the near future.
  • It then goes over all existing and new flights and makes sure they can be operated
  • It adds delays, cancels flights or adds transfers to make the overall flight plan work.

Interestingly, the StaleObjectStateExceptions never relate to the aircraft or their flights. It’s always an airline that shows up in the error. And in the majority of cases, it isn’t the direct operator of the aircraft, but a related company. Some context, with an emphasis on things that I think might be related to the issue:

  • Each aircraft has an operator (ManyToOne) of type Enterprise
  • Each Enterprise has an attribute parentCompany (ManyToOne) of type Company
  • That parentCompany always is an Enterprise
  • Enterprise is a sub-class of Company
  • Company is a sub-class of Addressee
  • The hierarchy is mapped as a @Inheritance(strategy = InheritanceType.JOINED)
  • Optimistic locking is enabled through a @Version private Long version; on Addressee

Now, the operator of an aircraft is actually modified during flight planning, but not always. But the parent companies almost certainly aren’t (and we never had this this issue in near 20 years of running this code). And yet, it now seems that Hibernate is overly cautious and assumes they have changed, causing these exceptions left and right.

The most effective change I have tried to far was setting the parentCompany attribute to be lazy-loaded. This reduced the amount of exceptions quite a bit, but it didn’t alleviate the problem completely.

So I would like to understand what might be causing this and what can be done to decrease the chances of this happening. Is my versioning set up correctly? Does my inheritance mapping require a review? Is there a way to to “help” Hibernate to make the right call?

I would be very thankful for your insights.

Did you go through the migration guides yet to see if one of the behavior changes might affect you? It sounds like you might be hit by something like this. Maybe an Airline object was created through new and attached to an object graph that then somehow triggers merge cascading. So say if you merge a Airline with the wrong optimistic version, obviously it will fail.

It would be helpful if you could create a reduced example to reproduce this issue for further debugging.

Thanks a lot for your reply!

I suspected as much, but I doubt that’s the reason. No airlines are created in this process. All companies either directly (as operators) or indirectly (as parent) involved already exist before the transaction. New flight objects are created which reference their operators, of course.

The overall system is highly concurrent. So it is likely (if not certain) that parent companies are in fact modified in parallel transactions. But the code is written in such a way that all transactions are serialised per operator, meaning a change to Airline A could happen at the same time as a change to Airline B, but no two changes to Airline A could happen in parallel. My suspicion is that if Airline A is a parent of Airline B and transactions happen for both at the same time, the transaction for Airline B falsely assumes that there’s a competing write to Airline A even if the object didn’t actually change in the Airline B transaction.

Is there any transitive mechanism like that that I need to be aware of? Where is the “right” location for the version field in a case like this? Should it be on Addressee (the shared root), the Company or the Enterprise?

Due to the verbosity of the actual code involved, I’m afraid this isn’t feasible. But if all else fails, I could try to reproduce this in vitro. But I have a gut-feeling about the chances of this succeeding :sweat_smile:

It’s hard to say what goes on exactly. Can you post the full stack traces of these StaleObjectStateException? That might help understand where this comes from.

This is one I grabbed straight off the logs:

2025-06-24T12:43:37.040Z ERROR 1 --- [s-dispatcher-43] d.a.c.EntityActor : StaleObjectStateException during operation AircraftOperationsOperation. Entity ID: 234, stale object ID: 231 org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [x.x.x.Enterprise#231]	at org.hibernate.engine.jdbc.mutation.internal.ModelMutationHelper.identifiedResultsCheck(ModelMutationHelper.java:75) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.persister.entity.mutation.UpdateCoordinatorStandard.lambda$doStaticUpdate$9(UpdateCoordinatorStandard.java:785) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.jdbc.mutation.internal.ModelMutationHelper.checkResults(ModelMutationHelper.java:50) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.performNonBatchedMutation(AbstractMutationExecutor.java:141) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.jdbc.mutation.internal.MutationExecutorStandard.lambda$performNonBatchedOperations$2(MutationExecutorStandard.java:258) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at java.base/java.util.TreeMap.forEach(Unknown Source) ~[?:?]	at org.hibernate.engine.jdbc.mutation.internal.PreparedStatementGroupStandard.forEachStatement(PreparedStatementGroupStandard.java:91) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.jdbc.mutation.internal.MutationExecutorStandard.performNonBatchedOperations(MutationExecutorStandard.java:258) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.execute(AbstractMutationExecutor.java:55) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.persister.entity.mutation.UpdateCoordinatorStandard.doStaticUpdate(UpdateCoordinatorStandard.java:781) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.persister.entity.mutation.UpdateCoordinatorStandard.performUpdate(UpdateCoordinatorStandard.java:328) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.persister.entity.mutation.UpdateCoordinatorStandard.update(UpdateCoordinatorStandard.java:245) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.action.internal.EntityUpdateAction.execute(EntityUpdateAction.java:169) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:644) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:511) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:414) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:41) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1429) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:491) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2354) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:1978) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:439) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:169) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:267) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:101) ~[hibernate-core-6.6.15.Final.jar:6.6.15.Final]	at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:562) ~[spring-orm-6.2.7.jar:6.2.7]	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:795) ~[spring-tx-6.2.7.jar:6.2.7]	at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:758) ~[spring-tx-6.2.7.jar:6.2.7]	at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:152) ~[spring-tx-6.2.7.jar:6.2.7] 

If you can debug the application while reproducing, I would suggest you put a breakpoint into the constructor of EntityUpdateAction with a condition to only observe events for Enterprise entities and inspect the thread stack when this unexpected thing happens. Maybe you can backtrack to find out why the entity is considered dirty.

Thanks a lot, I’ll try to look into it.

So I followed your recommendation. It’s really hard to isolate the exact situations I am looking for (because updates to Enterprise objects naturally happen all the time). But stopping on StaleObjectStateException being thrown and then having a look at the EntityUpdateAction frame produced the following lead: All such errors have in common that one of the dirty fields is an object map attribute of the Company class. Sometimes it’s the only dirty field.

The field is mapped as such:

@Column(name = "o_custom_settings_data") @JdbcTypeCode(Types.VARBINARY) @Convert(converter = ObjectMapConverter.class) private Map<String, Object> customSettingsData; 

This map is never modified automatically. Only by direct user interaction. So I figure it has something to do with how the “dirty-check” on that field is done. Maybe my mapping isn’t correct to begin with? Is a converter the right way to go here?

All assuming that this field is actually the culprit, of course.

There is nothing inherently wrong with your mapping, but surely there must be something wrong in your application if the map is mutated, even though you weren’t expecting any changes.

Solved it. There actually weren’t any active modifications of the maps. And it was in fact the dirty-check that caused the issue. But the bug lived 100% on my end of things, of course :smiley:

I feel a bit stupid now, but the map sometimes contained entries of type Object[] holding two serializable values. If two different instances of said array existed in the context, equals() failed despite the values being logically the same. Changed the serialisation format of those values and the exceptions disappeared. Future-me should probably revisit this whole map-based serialisation scheme in general…

Either way, thank you very much for your assistance!

1 Like