fix: declare synchronized entity classes on native writes DHIS2-21963 - #24803
Merged
Conversation
Native queries that run executeUpdate() without declaring which tables they touch make Hibernate conservatively evict EVERY L2 entity region, not just the affected one. NativeSQLQueryPlan.performExecuteUpdate calls coordinateSharedCacheCleanup with getCustomQuery().getQuerySpaces(). SQLCustomQuery leaves that set empty unless addSynchronizedEntityClass/QuerySpace was called, and BulkOperationCleanupAction.affectedEntity treats an empty set as "affects everything", so every persister that can write to cache gets an EntityCleanup. EntityCleanup.release() calls unlockRegion(), which is evictAll() -> evictData() on the whole region. Note AbstractReadWriteAccess.removeAll IS a no-op under read-write, which is why this is easy to miss; the eviction comes from unlockRegion, not removeAll. Both stores here use stateless sessions, where isEventSource() is false, so the cleanup runs immediately rather than being deferred to the ActionQueue. Draft to confirm the mechanism: measured on a Sierra Leone instance, a single data value write with a new period emptied the whole DataElement region (0 hits / 3037 logical misses on the next read), while the same write with an existing period left it fully warm. No eviction or removal counter moves and nothing is logged, so this is invisible without instrumenting Hibernate. HousekeepingJob runs every 20s by default and drives the jobconfiguration writes here, so on a stock instance every cached entity region is dropped on that cadence.
This was referenced Aug 7, 2026
This was referenced Aug 9, 2026
Merged
netroms
added a commit
to netroms/dhis2-core
that referenced
this pull request
Aug 10, 2026
…cache Subset of dhis2#24803 by @teleivo, included so this branch is testable standalone; drop this commit once dhis2#24803 merges. A native executeUpdate without synchronized query spaces gives Hibernate no way to know which cached entities are affected, so it invalidates EVERY second level cache region. The scheduler writes every ~20 seconds, so all caches were emptied at that cadence, and each wipe triggered a putFromLoad re-population storm under the region write lock. AI Assisted
teleivo
force-pushed
the
l2-cache-wipe-repro
branch
from
August 10, 2026 09:04
0ccc021 to
cd13328
Compare
A write to a collection table needs the collection's element entity declared, not the owning entity. An entity's query spaces are its own table(s) only (SingleTableEntityPersister), never the tables of the collections it owns, and BulkOperationCleanupAction resolves collection regions via getCollectionRolesByEntityParticipant, which is keyed by the collection's element entity. So synchronizing on the owner leaves its cached collection stale: * categories_categoryoptions needs CategoryOption, not Category * users_catdimensionconstraints needs Category, not User Confirmed against a running instance. With a category collection warm and in steady state (+1 hit, 0 misses per read), a merge of an unrelated pair of categories left a bystander category's cached collection reloading cold (+2 misses, +1 put), while an unrelated write left it fully warm. Also correct the comments in the category combo and data set stores, which reached the right regions but described the wrong reason, and expand the nativeSynchronizedQuery javadoc with the element-entity rule.
|
teleivo
marked this pull request as ready for review
August 10, 2026 13:08
teleivo
requested review from
a team,
ameenhere,
jason-p-pickering,
jbee and
netroms
August 10, 2026 13:42
netroms
approved these changes
Aug 10, 2026
jbee
approved these changes
Aug 10, 2026
jason-p-pickering
approved these changes
Aug 10, 2026
netroms
added a commit
to netroms/dhis2-core
that referenced
this pull request
Aug 11, 2026
dhis2#24803 already landed the fuller nativeSynchronizedQuery fix for HibernateJobConfigurationStore, including the FileResource query space. Drop the temporary local synchronizedNativeQuery subset from this branch and keep the PR's ehcache hot-region predefinitions.
This was referenced Aug 11, 2026
teleivo
added a commit
that referenced
this pull request
Aug 11, 2026
… [42] (#24847) * fix: declare synchronized entity classes on native writes DHIS2-21963 (#24803) * fix: declare synchronized entity classes on native writes Native queries that run executeUpdate() without declaring which tables they touch make Hibernate conservatively evict EVERY L2 entity region, not just the affected one. NativeSQLQueryPlan.performExecuteUpdate calls coordinateSharedCacheCleanup with getCustomQuery().getQuerySpaces(). SQLCustomQuery leaves that set empty unless addSynchronizedEntityClass/QuerySpace was called, and BulkOperationCleanupAction.affectedEntity treats an empty set as "affects everything", so every persister that can write to cache gets an EntityCleanup. EntityCleanup.release() calls unlockRegion(), which is evictAll() -> evictData() on the whole region. Note AbstractReadWriteAccess.removeAll IS a no-op under read-write, which is why this is easy to miss; the eviction comes from unlockRegion, not removeAll. Both stores here use stateless sessions, where isEventSource() is false, so the cleanup runs immediately rather than being deferred to the ActionQueue. Draft to confirm the mechanism: measured on a Sierra Leone instance, a single data value write with a new period emptied the whole DataElement region (0 hits / 3037 logical misses on the next read), while the same write with an existing period left it fully warm. No eviction or removal counter moves and nothing is logged, so this is invisible without instrumenting Hibernate. HousekeepingJob runs every 20s by default and drives the jobconfiguration writes here, so on a stock instance every cached entity region is dropped on that cadence. * fix: declare query spaces on the remaining native writes * fix: synchronize entities not join tables so cached collections are evicted * docs: explain query spaces in nativeSynchronizedQuery javadoc * refactor: add stateless session variant of nativeSynchronizedQuery * refactor: use nativeSynchronizedQuery in complete data set registration store * refactor: run audit trigger DDL via jdbcTemplate * fix: declare element entities for native writes to collection tables A write to a collection table needs the collection's element entity declared, not the owning entity. An entity's query spaces are its own table(s) only (SingleTableEntityPersister), never the tables of the collections it owns, and BulkOperationCleanupAction resolves collection regions via getCollectionRolesByEntityParticipant, which is keyed by the collection's element entity. So synchronizing on the owner leaves its cached collection stale: * categories_categoryoptions needs CategoryOption, not Category * users_catdimensionconstraints needs Category, not User Confirmed against a running instance. With a category collection warm and in steady state (+1 hit, 0 misses per read), a merge of an unrelated pair of categories left a bystander category's cached collection reloading cold (+2 misses, +1 put), while an unrelated write left it fully warm. Also correct the comments in the category combo and data set stores, which reached the right regions but described the wrong reason, and expand the nativeSynchronizedQuery javadoc with the element-entity rule. * test: expect OptionSet region to survive housekeeping DHIS2-21963 The housekeeping job no longer wipes every L2 entity region, so the OptionSet region keeps its entries across the job and the query after it hits the cache instead of reloading cold. This is the same assertion change master and 2.43 already carry from "fix: Replicate users with JDBC" (#23154), which is not on 2.42.
teleivo
added a commit
that referenced
this pull request
Aug 11, 2026
…#24803) (#24845) * fix: declare synchronized entity classes on native writes Native queries that run executeUpdate() without declaring which tables they touch make Hibernate conservatively evict EVERY L2 entity region, not just the affected one. NativeSQLQueryPlan.performExecuteUpdate calls coordinateSharedCacheCleanup with getCustomQuery().getQuerySpaces(). SQLCustomQuery leaves that set empty unless addSynchronizedEntityClass/QuerySpace was called, and BulkOperationCleanupAction.affectedEntity treats an empty set as "affects everything", so every persister that can write to cache gets an EntityCleanup. EntityCleanup.release() calls unlockRegion(), which is evictAll() -> evictData() on the whole region. Note AbstractReadWriteAccess.removeAll IS a no-op under read-write, which is why this is easy to miss; the eviction comes from unlockRegion, not removeAll. Both stores here use stateless sessions, where isEventSource() is false, so the cleanup runs immediately rather than being deferred to the ActionQueue. Draft to confirm the mechanism: measured on a Sierra Leone instance, a single data value write with a new period emptied the whole DataElement region (0 hits / 3037 logical misses on the next read), while the same write with an existing period left it fully warm. No eviction or removal counter moves and nothing is logged, so this is invisible without instrumenting Hibernate. HousekeepingJob runs every 20s by default and drives the jobconfiguration writes here, so on a stock instance every cached entity region is dropped on that cadence. * fix: declare query spaces on the remaining native writes * fix: synchronize entities not join tables so cached collections are evicted * docs: explain query spaces in nativeSynchronizedQuery javadoc * refactor: add stateless session variant of nativeSynchronizedQuery * refactor: use nativeSynchronizedQuery in complete data set registration store * refactor: run audit trigger DDL via jdbcTemplate * fix: declare element entities for native writes to collection tables A write to a collection table needs the collection's element entity declared, not the owning entity. An entity's query spaces are its own table(s) only (SingleTableEntityPersister), never the tables of the collections it owns, and BulkOperationCleanupAction resolves collection regions via getCollectionRolesByEntityParticipant, which is keyed by the collection's element entity. So synchronizing on the owner leaves its cached collection stale: * categories_categoryoptions needs CategoryOption, not Category * users_catdimensionconstraints needs Category, not User Confirmed against a running instance. With a category collection warm and in steady state (+1 hit, 0 misses per read), a merge of an unrelated pair of categories left a bystander category's cached collection reloading cold (+2 misses, +1 put), while an unrelated write left it fully warm. Also correct the comments in the category combo and data set stores, which reached the right regions but described the wrong reason, and expand the nativeSynchronizedQuery javadoc with the element-entity rule.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



The bug
A native query that calls
executeUpdate()without declaring which tables it touches makes Hibernate discard every L2 entity region, not just the one it wrote to.HibernateNativeStore.nativeSynchronizedQueryalready exists for exactly this and its javadoc says so ("Use this to avoid all Hibernate second level caches from being invalidated"), but these call sites bypass it.About 32 call sites share this shape and all do the same damage. The one that matters most is
HousekeepingJob, only because of how often it runs: every 20 seconds by default (JobType.java#L120), with all writes unsynchronized, so every cached entity region is dropped on that cadence no matter what the instance is doing. The rest are mostly admin-driven merges and deletes that wipe just as thoroughly but rarely enough to go unnoticed. This PR fixes all of them; a scan forcreateNativeQuery(...).executeUpdate()without anaddSynchronized*now returns nothing.Before / after
Reproducible from
masteron a Sierra Leone database, with no code or test-module changes. The only requirement is twodhis.confflags to expose the counters:Input. One request, which reads every data element so it touches every key in the
DataElementregion:Numbers below are from a database widened to 3037 data elements for unrelated tracker load tests; stock Sierra Leone has 1037.
Cadence. 9 requests, with a 30s idle gap between them:
30s comfortably clears the 20s housekeeping interval, so the window spans at least one tick. Request 1 is unavoidably cold, leaving 8 as the test: each is either a pure hit if the region kept its entries, or cold if it did not. The two outcomes are far apart and cannot be confused: 8 x 3037 = 24,296 hits, or ~0 hits and 9 cold fills.
Counters are
ehcache_*{cache="DataElement",type="L2"}, since DHIS2 backs Hibernate's L2 with Ehcache 3 via JCache. Note one Hibernate miss counts as 2 Ehcache misses + 1 put, becauseputFromLoadre-reads the key before installing it.Result. Same script, same database, baseline first then this branch:
master(core-dev@d1eb41db)Both sides account for every lookup exactly. This PR hits 24,296 = 8 x 3037, the maximum possible, and its 6,074 misses are one cold-start fill. On
masterevery request started cold.Hits and misses per minute,
masterthen this branch, switching over mid-run:At the switchover misses (blue) collapse to zero while hits (green) take over, and hold there for the rest of the run, so the region is warm and serving traffic rather than idle.
A second test pins the cause to a single statement, with no idle gap or background job involved.
HibernatePeriodStore.save()only runs itsINSERTwhen the period row is missing, so onmasteronePOST /api/dataValueswith a new period empties the region, while the identical request repeated (period now exists,INSERTskipped) leaves it warm. Same endpoint, same payload shape, opposite outcome. On this branch both leave it warm.Mechanism
The API to use is
SynchronizeableQuery, whichNativeQueryextends. It providesaddSynchronizedEntityClass,addSynchronizedEntityNameandaddSynchronizedQuerySpace.Hibernate's own documented statement of the behaviour is the
@implNoteonBulkOperationCleanupAction.affectedEntity:"Not known" is exactly the case here: an empty query-space set means every entity is treated as affected. The chain in 5.6.15:
NativeSQLQueryPlan.coordinateSharedCacheCleanup:54buildsnew BulkOperationCleanupAction(session, getCustomQuery().getQuerySpaces())before the SQL runs, andSQLCustomQuery:42leaves that set empty unless anaddSynchronized*call fills it.affectedEntitythen returnstruefor everything, so every persister withcanWriteToCache()gets anEntityCleanup, whoserelease()callsunlockRegion->evictAll()->evictData(), emptying the region.The eviction is the easy part to miss.
AbstractReadWriteAccess.removeAll:209is a no-op underread-write, so reading only that suggests nothing can be evicted. ButEntityCleanupcalls bothremoveAllandunlockRegion, and only the former is neutered.In
HibernatePeriodStoreandHibernateJobConfigurationStore, which write throughopenStatelessSession,StatelessSessionImpl.isEventSource()returnsfalse, so the cleanup runs immediately and synchronously instead of being deferred to theActionQueue.Caveat on sourcing. The 5.6 reference guide documents none of this: its native query chapter never mentions query spaces or the L2 consequences of a native bulk write, and the
SynchronizeableQueryjavadoc describes spaces as affecting only auto-flush and query result caching. The claim rests on the@implNoteabove, the source chain, and the measurements here.Collections need the element entity, not the owner
Declaring a space is not enough on its own, it has to resolve to the regions you mean, and for a write to a join table the obvious declaration resolves to nothing.
An entity's query spaces are its own table(s) only, never the tables of the collections it owns.
CategoryownscategoryOptions, stored incategories_categoryoptions, butCategory's spaces are just{category}:The second works because collection regions are not matched by table name at all. Hibernate looks them up in
collectionRolesByEntityParticipant, a map keyed by each collection's element type. It holdsCategoryOption -> {Category.categoryOptions, ...}; there is noCategory -> {Category.categoryOptions}entry to find. Owning a collection does not get you into that map, being an element of one does.So declare the entity on the far side of the association:
categories_categoryoptionsneedsCategoryOption,users_catdimensionconstraintsneedsCategory. Fordatasetelement,DataSetElementis both its own entity and the element ofDataSet.dataSetElements, so naming it covers both regions.Getting this wrong is easy to miss. The entity region still gets evicted, so a re-fetched
Categoryhas fresh fields and the write looks correct; only its cached option list is stale. A cold cache reloads from the database and hides it entirely, so it only shows up when the collection was cached before the write.Why the wipe is hard to attribute
It is measurable, as above, but nothing points at it. It surfaces as a poor hit ratio, which has many plausible causes, and the obvious checks all come back clean:
DefaultHibernateCacheManager.clearCache(), which does log, is not involved, and DEBUG onorg.hibernate.cache.spi.support,org.hibernate.cache.jcache,org.hibernate.action.internalandorg.ehcachestays silent through a confirmed wipe.ehcache_evictions_totalandehcache_removals_totalboth stay 0, because a bulk region clear is not counted per key. They cannot be used to rule this out.ehcache.xmlgives every region 1,000,000 entries and a 6h ttl, against a few thousand entities, and this happens within seconds.Also ruled out by measurement: the update-timestamps region, key instability, concurrency (a single sequential client reproduces it), and
clearCache().How this compounds the Uganda tracker issue
Found while investigating stalled
/api/trackerimports on a Uganda instance (82s wall for 1.3s CPU, ~89% of samples parked on theDataElementL2 region lock). That contention is #24773's subject; as described there, the lock is only expensive on misses, since hits share a read lock while each miss takes it exclusively.These wipes are what keep supplying the misses: the region is emptied every 20s, so imports repeatedly face a cold cache and pay an exclusive acquisition per data element. The wipes do not cause the contention, they stop it ever settling. Not tracker specific either, the same cycle hits every cached entity on every instance; tracker import just reads enough of one region per request to make it visible.