perf: store all second level cache regions by reference - #24924
Conversation
Every cached region in DHIS2 uses READ_WRITE, whose access strategy holds one ReentrantReadWriteLock PER REGION (not per key): every get takes the region read lock and every putFromLoad takes the region WRITE lock, even when it writes nothing (entities are unversioned, so an existing entry is never overwritten from a load). Under concurrent load on option-heavy metadata this serialises a whole region behind single-key work: measured 14-15% of ALL JVM wall samples parked in AbstractReadWriteAccess. Switches the static reference bucket (Option*, PeriodType, DataElement, Category*, OptionSet.options, Legend*, Indicator*, OrgUnit hierarchy; 30 files, 71 declarations) to NONSTRICT_READ_WRITE, which has no region lock. Trade-off: a brief staleness window after a write, accepted for reference metadata (sign-off: Morten, 2026-08-07). Period/RelativePeriods stay READ_WRITE. Measured on a tracker-import ramp: parked-in-region-lock 14.15% -> 0.05%, p99 at 100 concurrent users 58.4s -> 20.7s as an isolated change. AI Assisted
Regions created on demand through the jsr107 template are store-by-value: every get and put copies the entry through SerializingCopier, inside the READ_WRITE region lock critical section. Predefining a region in ehcache.xml keeps ehcache-native store-by-reference semantics (Hibernate caches disassembled, immutable entries, so by-reference is safe; 2.41 ran Ehcache 2 by-reference for years). Region list and heap bounds come from measured traffic: the hot metadata regions from a read/write metadata ramp, plus the tracker-import hot set (the Option region alone takes ~98M gets per 25 minute import run). Measured effect in the full combination: SerializingCopier wall samples 236,287 -> 1,057. AI Assisted
dataElementCountDoesNotScaleQueryCount compares a metadata export with 3 data elements against one with 8 and asserted the two select counts were equal. With the cache changes in this branch the second export legitimately issues FEWER selects (21 vs 22): what the first export loaded is still cached during the second. Assert the invariant the test name and comment state - the count must not grow - so the test keeps catching a reintroduced N+1 without failing on a cache improvement. AI Assisted
The previous six hour TTL was inherited from the old Ehcache 2 configuration. TTL is the upper bound on how long a stale cache entry can survive, whether it went stale through an out-of-band database change (no load ever refreshes an existing entry for unversioned entities) or through a late cache-put re-inserting a value just evicted by a concurrent write under NONSTRICT_READ_WRITE. One hour tightens that bound for every region at the cost of one reload per entry per hour, which is noise at production request rates. Instances with stricter staleness expectations can override the whole file via cache.ehcache.config.file. Also rewrites the region declaration comments in neutral terms: why declared caches are store-by-reference while runtime-created ones are forced store-by-value by the JCache defaults, how the region list and heap bounds were chosen, and how to tune both from the per-region metrics exposed at /api/metrics. AI Assisted
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #24924 +/- ##
=============================================
+ Coverage 58.05% 70.04% +11.98%
+ Complexity 1414 725 -689
=============================================
Files 3725 3733 +8
Lines 144739 145375 +636
Branches 16875 16936 +61
=============================================
+ Hits 84033 101822 +17789
+ Misses 53550 35730 -17820
- Partials 7156 7823 +667
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 1087 files with indirect coverage changes Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
netroms
left a comment
There was a problem hiding this comment.
Verdict: correct and well-tested — no blocking issues found. This PR adds a CacheManager-level <default-copiers> block to ehcache.xml (mapping java.lang.Object to IdentityCopier) to restore store-by-reference for Hibernate L2 regions that hibernate-jcache creates at runtime, plus a pinning test.
I verified every material claim empirically against the exact runtime stack (ehcache 3.12.0-jakarta, hibernate-jcache 5.6.15.Final, JSR-107 cache-api 1.1.1) by loading the actual ehcache.xml and reproducing the test:
- XML is schema-valid / element order correct. Ehcache validates against its XSD during parse and the file loaded without error.
<default-copiers>sits legally between<service>and<cache-template>per ehcache-core-3.10. org.ehcache.impl.copy.IdentityCopierhas a public no-arg constructor (confirmed via javap), so the XML instantiates it reflectively — no risk of a startupCacheException.- The block does override JSR-107 store-by-value. A cache created exactly like hibernate-jcache (
createCache(name, new MutableConfiguration<>()), which defaults storeByValue=true) returned the same instance on repeated gets — store-by-reference. - Non-vacuous. Removing the block, the same runtime cache serialized (two gets returned different instances), proving the block is doing real work and the test would fail if it were deleted.
- Test faithfully mirrors production. hibernate-jcache 5.6.15
JCacheRegionFactory.createCachebytecode isnew MutableConfiguration<>()thencacheManager.createCache(...)with nosetStoreByValue, matching the test'screateRuntimeCache. - Declared caches (e.g.
org.hisp.dhis.option.Option) are already ehcache-native by-reference; the default copier doesn't change them.
Safety of by-reference for Hibernate L2 is sound: entries are Hibernate-internal disassembled (immutable) state, never handed to application code, matching the Ehcache-2 behavior DHIS2 ran through 2.41. The S
experimental reviewbot, MC container 6b4b31d20b33
There was a problem hiding this comment.
Pull request overview
Restores store-by-reference semantics for all Hibernate L2 cache regions, avoiding serialization overhead in runtime-created regions.
Changes:
- Configures
IdentityCopieras the default Ehcache copier. - Adds regression tests for declared and runtime-created regions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
ehcache.xml |
Enables store-by-reference globally. |
StoreByReferenceConfigTest.java |
Verifies reference identity for both cache creation paths. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| <default-copiers> | ||
| <copier type="java.lang.Object">org.ehcache.impl.copy.IdentityCopier</copier> | ||
| </default-copiers> |
What this PR changes
Every L2 region not explicitly declared in
ehcache.xmlis created at runtime by hibernate-jcache with an empty JCacheMutableConfiguration, whose spec default is store-by-value. Ehcache implements that by installingSerializingCopier: a serialize plus deserialize round trip on every get and every put, and underREAD_WRITEit runs inside the region lock critical section. The block above overrides that default at the CacheManager level (runtime-created Hibernate caches are all typedObject/Object), so every region stores by reference, like the regions #24810 declares explicitly.Why this is a restore, not a new idea
DHIS2 stored every region by reference from 2016 through 2.41: Ehcache 2's on-heap store holds references, and the opt-in
copyOnRead/copyOnWriteflags were never set. The 2.42 Spring 6 upgrade (#18314) moved to hibernate-jcache + Ehcache 3, and by-value arrived as a silent side effect of the JCache spec default: no commit chose it, and no config file shows it, because it lives inMutableConfiguration's constructor. The Apr 2025 config migration (#20507) restored sizing and expiry through the jsr107 template, but a template without a copier config does not opt out of by-value (ConfigurationMerger.handleStoreByValue). This PR closes that last gap.By-reference is safe for Hibernate's use of the cache: entries are Hibernate-internal disassembled state, never handed to application code and never mutated in place, which is why Ehcache 2, Infinispan's Hibernate integration, and every serious L2 backend store by reference. One consequence to know: entries are no longer required to be
Serializable. That was only ever an accidental tripwire; a deployment overriding the file to add off-heap or disk tiers must configure serializers, exactly as before 2.42.Measured effect
Same-base pair (current master + #24810, with and without this change), instrumented harness (#24767 kit), Sierra Leone DB, read-heavy and write-mixed metadata ramps 10 to 400 users, same box, same day, zero failed requests on all arms:
SerializingCopierwall samplesHonest framing: throughput is flat to marginally positive, because #24810 already declared the measured-hot regions by reference; what remains by-value after it is the long tail (~200 regions, mostly the
READ_WRITEbucket), which these workloads barely heat. The copier is provably eliminated (zero samples), latency percentiles are unchanged, and the change removes a per-get/per-put CPU tax from every region that becomes hot in some workload we did not measure. This PR ships on the restore argument, with the measurement as a safety proof, not as a performance claim.Test
StoreByReferenceConfigTestcreates a cache through the exact hibernate-jcache code path (cacheManager.createCache(name, new MutableConfiguration<>())) against the shippedehcache.xmland asserts two gets return the same instance. On the previous config it fails with two distinct deserialized copies, which is the copier caught in the act; remove the<default-copiers>block and the test fails again. A declared-region control pins that explicit caches were already by reference.HibernateCacheEffectTest(real SessionFactory + ehcache) andHibernateEhcacheConfigFileTest(Postgres) pass unchanged.AI Assisted