Skip to content

fix: declare synchronized entity classes on native writes DHIS2-21963 - #24803

Merged
jason-p-pickering merged 8 commits into
masterfrom
l2-cache-wipe-repro
Aug 10, 2026
Merged

fix: declare synchronized entity classes on native writes DHIS2-21963#24803
jason-p-pickering merged 8 commits into
masterfrom
l2-cache-wipe-repro

Conversation

@teleivo

@teleivo teleivo commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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.

// evicts every cached entity in the JVM
getSession().createNativeQuery("update jobconfiguration set ...").executeUpdate();

// evicts only the JobConfiguration region
getSession().createNativeQuery("update jobconfiguration set ...")
    .addSynchronizedEntityClass(JobConfiguration.class)
    .executeUpdate();

HibernateNativeStore.nativeSynchronizedQuery already 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 for createNativeQuery(...).executeUpdate() without an addSynchronized* now returns nothing.

Before / after

Reproducible from master on a Sierra Leone database, with no code or test-module changes. The only requirement is two dhis.conf flags to expose the counters:

monitoring.hibernate.enabled = on
monitoring.ehcache.enabled   = on

Input. One request, which reads every data element so it touches every key in the DataElement region:

GET /api/dataElements?fields=id,name,optionSet&paging=false

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:

for i in $(seq 1 8); do request; sleep 30; done
request

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, because putFromLoad re-reads the key before installing it.

Result. Same script, same database, baseline first then this branch:

master (core-dev@d1eb41db) this PR
hits 2,746 24,296
misses 55,248 6,074
hit ratio 4.7% 80%
steady-state misses/min 12,354 0
evictions / removals 0 / 0 0 / 0

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 master every request started cold.

Hits and misses per minute, master then this branch, switching over mid-run:

hits and misses per minute across the switchover

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 its INSERT when the period row is missing, so on master one POST /api/dataValues with a new period empties the region, while the identical request repeated (period now exists, INSERT skipped) leaves it warm. Same endpoint, same payload shape, opposite outcome. On this branch both leave it warm.

Mechanism

The API to use is SynchronizeableQuery, which NativeQuery extends. It provides addSynchronizedEntityClass, addSynchronizedEntityName and addSynchronizedQuerySpace.

Hibernate's own documented statement of the behaviour is the @implNote on BulkOperationCleanupAction.affectedEntity:

An entity is considered to be affected if either (1) the affected table spaces are not known or (2) any of the incoming check table spaces occur in that set.

"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:

  1. NativeSQLQueryPlan.coordinateSharedCacheCleanup:54 builds new BulkOperationCleanupAction(session, getCustomQuery().getQuerySpaces()) before the SQL runs, and SQLCustomQuery:42 leaves that set empty unless an addSynchronized* call fills it.
  2. affectedEntity then returns true for everything, so every persister with canWriteToCache() gets an EntityCleanup, whose release() calls unlockRegion -> evictAll() -> evictData(), emptying the region.

The eviction is the easy part to miss. AbstractReadWriteAccess.removeAll:209 is a no-op under read-write, so reading only that suggests nothing can be evicted. But EntityCleanup calls both removeAll and unlockRegion, and only the former is neutered.

In HibernatePeriodStore and HibernateJobConfigurationStore, which write through openStatelessSession, StatelessSessionImpl.isEventSource() returns false, so the cleanup runs immediately and synchronously instead of being deferred to the ActionQueue.

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 SynchronizeableQuery javadoc describes spaces as affecting only auto-flush and query result caching. The claim rests on the @implNote above, 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. Category owns categoryOptions, stored in categories_categoryoptions, but Category's spaces are just {category}:

// writes categories_categoryoptions, the Category.categoryOptions collection table
.addSynchronizedEntityClass(Category.class)       // spaces = {category}      -> collection NOT evicted
.addSynchronizedEntityClass(CategoryOption.class) // spaces = {categoryoption} -> collection evicted

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 holds CategoryOption -> {Category.categoryOptions, ...}; there is no Category -> {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_categoryoptions needs CategoryOption, users_catdimensionconstraints needs Category. For datasetelement, DataSetElement is both its own entity and the element of DataSet.dataSetElements, so naming it covers both regions.

Getting this wrong is easy to miss. The entity region still gets evicted, so a re-fetched Category has 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:

  • nothing is logged. DefaultHibernateCacheManager.clearCache(), which does log, is not involved, and DEBUG on org.hibernate.cache.spi.support, org.hibernate.cache.jcache, org.hibernate.action.internal and org.ehcache stays silent through a confirmed wipe.
  • ehcache_evictions_total and ehcache_removals_total both stay 0, because a bulk region clear is not counted per key. They cannot be used to rule this out.
  • it is not capacity or TTL. ehcache.xml gives 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/tracker imports on a Uganda instance (82s wall for 1.3s CPU, ~89% of samples parked on the DataElement L2 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.

teleivo added 3 commits August 7, 2026 11:05
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.
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
@dhis2 dhis2 deleted a comment from codecov Bot Aug 10, 2026
@teleivo
teleivo force-pushed the l2-cache-wipe-repro branch from 0ccc021 to cd13328 Compare August 10, 2026 09:04
@teleivo teleivo changed the title fix: declare synchronized entity classes on native writes fix: declare synchronized entity classes on native writes DHIS2-21963 Aug 10, 2026
@dhis2 dhis2 deleted a comment from codecov Bot Aug 10, 2026
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.
@sonarqubecloud

Copy link
Copy Markdown

@teleivo
teleivo marked this pull request as ready for review August 10, 2026 13:08
@teleivo
teleivo requested a review from a team as a code owner August 10, 2026 13:08

@jason-p-pickering jason-p-pickering left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great find!

@jason-p-pickering
jason-p-pickering merged commit 38bab12 into master Aug 10, 2026
25 checks passed
@jason-p-pickering
jason-p-pickering deleted the l2-cache-wipe-repro branch August 10, 2026 19:27
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants