Skip to content

perf: store all second level cache regions by reference - #24924

Open
netroms wants to merge 6 commits into
dhis2:masterfrom
netroms:l2-cache-byref-default
Open

perf: store all second level cache regions by reference#24924
netroms wants to merge 6 commits into
dhis2:masterfrom
netroms:l2-cache-byref-default

Conversation

@netroms

@netroms netroms commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What this PR changes

<default-copiers>
  <copier type="java.lang.Object">org.ehcache.impl.copy.IdentityCopier</copier>
</default-copiers>

Every L2 region not explicitly declared in ehcache.xml is created at runtime by hibernate-jcache with an empty JCache MutableConfiguration, whose spec default is store-by-value. Ehcache implements that by installing SerializingCopier: a serialize plus deserialize round trip on every get and every put, and under READ_WRITE it runs inside the region lock critical section. The block above overrides that default at the CacheManager level (runtime-created Hibernate caches are all typed Object/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/copyOnWrite flags 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 in MutableConfiguration'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:

workload rps delta per step (c010..c400) SerializingCopier wall samples region lock wall samples
read-heavy -1.1% to +1.7% (noise) 1,486 to 0 2,013 to 1,352
write-mixed (5% PATCH) -2.4% to +3.1% (best at c400) 2,106 to 0 3,598 to 2,005

Honest 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_WRITE bucket), 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

StoreByReferenceConfigTest creates a cache through the exact hibernate-jcache code path (cacheManager.createCache(name, new MutableConfiguration<>())) against the shipped ehcache.xml and 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) and HibernateEhcacheConfigFileTest (Postgres) pass unchanged.

AI Assisted

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

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.04%. Comparing base (e15b4d9) to head (b5012ef).
⚠️ Report is 51 commits behind head on master.

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     
Flag Coverage Δ
integration 50.40% <ø> (+10.62%) ⬆️
integration-h2 28.06% <ø> (?)
unit 36.27% <ø> (+0.49%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...src/main/java/org/hisp/dhis/category/Category.java 61.63% <ø> (+6.91%) ⬆️
...ain/java/org/hisp/dhis/category/CategoryCombo.java 68.25% <ø> (+2.38%) ⬆️
...in/java/org/hisp/dhis/category/CategoryOption.java 73.61% <ø> (+9.02%) ⬆️
...in/java/org/hisp/dhis/dataelement/DataElement.java 76.19% <ø> (+7.73%) ⬆️
...n/java/org/hisp/dhis/indicator/IndicatorGroup.java 54.54% <ø> (+1.13%) ⬆️
...ava/org/hisp/dhis/indicator/IndicatorGroupSet.java 27.65% <ø> (+1.06%) ⬆️
...api/src/main/java/org/hisp/dhis/legend/Legend.java 74.69% <ø> (+1.20%) ⬆️
.../src/main/java/org/hisp/dhis/legend/LegendSet.java 51.68% <ø> (+2.24%) ⬆️
.../src/main/java/org/hisp/dhis/option/OptionSet.java 62.76% <ø> (ø)

... and 1087 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2430dfb...b5012ef. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@netroms
netroms marked this pull request as ready for review August 21, 2026 08:35
@netroms netroms added the ai-review Queue this PR for the experimental MC review bot label Aug 24, 2026
netroms

This comment was marked as outdated.

netroms

This comment was marked as outdated.

@netroms netroms left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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:

  1. 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.
  2. org.ehcache.impl.copy.IdentityCopier has a public no-arg constructor (confirmed via javap), so the XML instantiates it reflectively — no risk of a startup CacheException.
  3. 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.
  4. 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.
  5. Test faithfully mirrors production. hibernate-jcache 5.6.15 JCacheRegionFactory.createCache bytecode is new MutableConfiguration<>() then cacheManager.createCache(...) with no setStoreByValue, matching the test's createRuntimeCache.
  6. 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Restores store-by-reference semantics for all Hibernate L2 cache regions, avoiding serialization overhead in runtime-created regions.

Changes:

  • Configures IdentityCopier as 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.

Comment on lines +34 to +36
<default-copiers>
<copier type="java.lang.Object">org.ehcache.impl.copy.IdentityCopier</copier>
</default-copiers>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Queue this PR for the experimental MC review bot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants