diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/Category.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/Category.hbm.xml index 83cbc4ca5f4c..47cde3767e1b 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/Category.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/Category.hbm.xml @@ -8,7 +8,7 @@ - + @@ -30,7 +30,7 @@ not-null="true"/> - + @@ -40,7 +40,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryCombo.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryCombo.hbm.xml index b59bc335ea8a..e2d4fa13702c 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryCombo.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryCombo.hbm.xml @@ -8,7 +8,7 @@ - + @@ -20,7 +20,7 @@ - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryDimension.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryDimension.hbm.xml index 34f17b542d98..ac15abaa5f82 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryDimension.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryDimension.hbm.xml @@ -6,7 +6,7 @@ - + @@ -16,7 +16,7 @@ foreign-key="fk_categorydimension_category" /> - + - + @@ -32,27 +32,27 @@ - + - + - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionCombo.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionCombo.hbm.xml index 7f3990e756eb..35cef26994bc 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionCombo.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionCombo.hbm.xml @@ -8,7 +8,7 @@ - + @@ -20,7 +20,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroup.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroup.hbm.xml index 5a0aed492b96..dde1777d2fc7 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroup.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroup.hbm.xml @@ -8,7 +8,7 @@ - + @@ -24,14 +24,14 @@ - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSet.hbm.xml index 3c98531a8f9f..04dc7e8135c2 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSet.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSet.hbm.xml @@ -8,7 +8,7 @@ - + @@ -26,7 +26,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSetDimension.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSetDimension.hbm.xml index 76199b2b1845..1e62d273551f 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSetDimension.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/category/hibernate/CategoryOptionGroupSetDimension.hbm.xml @@ -6,7 +6,7 @@ - + @@ -16,7 +16,7 @@ foreign-key="fk_dimension_categoryoptiongroupsetid" /> - + - + @@ -54,19 +54,19 @@ - + - + - + @@ -81,7 +81,7 @@ foreign-key="fk_dataelement_commentoptionsetid" /> - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroup.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroup.hbm.xml index 96a1c3506f05..f054285320c7 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroup.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroup.hbm.xml @@ -8,7 +8,7 @@ - + @@ -24,14 +24,14 @@ - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroupSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroupSet.hbm.xml index 3a3bf8b9b264..6759f658ecd9 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroupSet.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataelement/hibernate/DataElementGroupSet.hbm.xml @@ -9,7 +9,7 @@ - + @@ -31,7 +31,7 @@ - + - + @@ -16,7 +16,7 @@ foreign-key="fk_dimension_dataelementgroupsetid" /> - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/Indicator.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/Indicator.hbm.xml index 6e6bb75af306..a87a7fe615de 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/Indicator.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/Indicator.hbm.xml @@ -8,7 +8,7 @@ - + @@ -45,19 +45,19 @@ - + - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroup.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroup.hbm.xml index 3813b72cd335..6d2be9122fe3 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroup.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroup.hbm.xml @@ -8,7 +8,7 @@ - + @@ -22,7 +22,7 @@ - + @@ -37,7 +37,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroupSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroupSet.hbm.xml index 6b9354f75756..0b8b3a49375f 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroupSet.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorGroupSet.hbm.xml @@ -8,7 +8,7 @@ - + @@ -28,7 +28,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorType.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorType.hbm.xml index 3f5dad21252f..8ea70707844e 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorType.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/indicator/hibernate/IndicatorType.hbm.xml @@ -8,7 +8,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/Legend.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/Legend.hbm.xml index 513551e08042..4e0fa2e206c2 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/Legend.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/Legend.hbm.xml @@ -8,7 +8,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/LegendSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/LegendSet.hbm.xml index b7f579822d4f..790b8fa51414 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/LegendSet.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/legend/hibernate/LegendSet.hbm.xml @@ -8,7 +8,7 @@ - + @@ -20,7 +20,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/Option.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/Option.hbm.xml index 6c7ad80a99d2..31c0dc7cbf7f 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/Option.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/Option.hbm.xml @@ -8,7 +8,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroup.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroup.hbm.xml index 0dbe22478cc5..86cf79fb13a2 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroup.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroup.hbm.xml @@ -8,7 +8,7 @@ - + @@ -24,7 +24,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroupSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroupSet.hbm.xml index 5317ae76bc1f..81a83faba9f4 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroupSet.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/option/hibernate/OptionGroupSet.hbm.xml @@ -8,7 +8,7 @@ - + @@ -24,7 +24,7 @@ - + - + @@ -32,7 +32,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnit.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnit.hbm.xml index fc24b06c44c4..a3b96f1045fe 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnit.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnit.hbm.xml @@ -8,7 +8,7 @@ - + @@ -20,7 +20,7 @@ - + @@ -47,31 +47,31 @@ - + - + - + - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroup.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroup.hbm.xml index b2a971844317..da3cc6a9aa3c 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroup.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroup.hbm.xml @@ -8,7 +8,7 @@ - + @@ -28,14 +28,14 @@ - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSet.hbm.xml index 020a3d21b061..085e5e50fd81 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSet.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSet.hbm.xml @@ -8,7 +8,7 @@ - + @@ -32,7 +32,7 @@ - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSetDimension.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSetDimension.hbm.xml index 92bd53fa7bd3..7c10e3889f92 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSetDimension.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/organisationunit/hibernate/OrganisationUnitGroupSetDimension.hbm.xml @@ -6,7 +6,7 @@ - + @@ -16,7 +16,7 @@ foreign-key="fk_dimension_orgunitgroupsetid" /> - + - + diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/period/hibernate/PeriodType.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/period/hibernate/PeriodType.hbm.xml index a61d2b0aa361..063f59e86bf5 100644 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/period/hibernate/PeriodType.hbm.xml +++ b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/period/hibernate/PeriodType.hbm.xml @@ -6,7 +6,7 @@ - + diff --git a/dhis-2/dhis-support/dhis-support-hibernate/pom.xml b/dhis-2/dhis-support/dhis-support-hibernate/pom.xml index a1068b3a3136..fd7fe94d2975 100644 --- a/dhis-2/dhis-support/dhis-support-hibernate/pom.xml +++ b/dhis-2/dhis-support/dhis-support-hibernate/pom.xml @@ -150,6 +150,11 @@ 3.3.5 test + + com.h2database + h2 + test + @@ -261,6 +266,7 @@ javax.annotation:javax.annotation-api org.glassfish.jaxb:jaxb-runtime org.ehcache:ehcache + com.h2database:h2 diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/EvictionGuard.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/EvictionGuard.java new file mode 100644 index 000000000000..df6757ef3f27 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/EvictionGuard.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +/** + * Lock-free record of recent L2 cache evictions for NONSTRICT_READ_WRITE regions. + * + *

A put is allowed only if its transaction's caching timestamp is strictly newer than the key's + * last recorded eviction and the region's last recorded clear. This is the refusal READ_WRITE gets + * from its SoftLock unlock timestamp, without the per-region lock, and the same discipline as + * Infinispan's NonStrictAccessDelegate. + * + *

Memory is bounded by time, not size: records live in two rotating generations and are dropped + * after surviving two rotations. Guaranteed retention is one window, thirty minutes of JVM running + * time, sized to exceed the longest plausible transaction (analytics table generation and metadata + * import run for many minutes). If a transaction still outlives its records, the guard forgives a + * put it should have refused: that key degrades to plain unguarded NONSTRICT_READ_WRITE behaviour, + * a bounded staleness risk, never corruption. There is no entry cap, so a mass metadata import can + * retain 1e5 to 1e6 keys, tens of MB of transient heap, for up to an hour. + * + *

Both bars only move forward: a region clear never wipes per-key records, and out-of-order + * records cannot lower either bar. A put must clear both. The generation pair lives behind one + * volatile field, which {@link #isPutAllowed} dereferences exactly once, so a rotation cannot hide + * a record between its two generation lookups. {@link #recordEviction} rereads the field after + * writing and repeats the record if a rotation demoted its target generation, so a record always + * lands in a generation with a full window ahead of it. + * + *

No wall-clock time anywhere. Refusals compare Hibernate cache timestamps ({@code + * SimpleTimestamper}: wall-seeded, monotone under CAS), so refusal ordering survives clock steps in + * either direction. Rotation uses {@link System#nanoTime}, so a suspended VM does not age the + * windows; it does not advance in-flight transactions on this JVM either, which is the coherent + * pairing. + * + * @author Morten Svanæs + */ +public final class EvictionGuard { + /** + * One window is the guaranteed retention, so it must exceed the longest plausible transaction; + * thirty minutes covers the long DHIS2 ones. The class javadoc states the failure mode for a + * transaction that outlives its records. + */ + private static final long DEFAULT_WINDOW_NANOS = TimeUnit.MINUTES.toNanos(30); + + private final LongSupplier clock; + private final long windowNanos; + private final AtomicLong regionClearTimestamp = new AtomicLong(Long.MIN_VALUE); + + /** The only mutable state reachable by readers, always replaced as a whole. */ + private volatile Generations generations; // NOSONAR java:S3077 - replaced as a whole + + public EvictionGuard() { + this(System::nanoTime, DEFAULT_WINDOW_NANOS); + } + + EvictionGuard(LongSupplier clock, long windowNanos) { // visible for tests + this.clock = clock; + this.windowNanos = windowNanos; + long start = clock.getAsLong(); + this.generations = new Generations(new Generation(start), new Generation(start)); + } + + public void recordEviction(Object key, long cacheTimestamp) { + Generation target; + do { + target = rotated().current; + target.evictions.merge(key, cacheTimestamp, Math::max); + // a rotation between the two statements above would leave the record in a generation whose + // remaining lifetime is already partly spent, so record again into the new current one + } while (generations.current != target); + } + + public void recordClearAll(long cacheTimestamp) { + regionClearTimestamp.accumulateAndGet(cacheTimestamp, Math::max); + } + + public boolean isPutAllowed(Object key, long txTimestamp) { + if (txTimestamp <= regionClearTimestamp.get()) return false; + Generations pair = rotated(); + return allows(pair.current, key, txTimestamp) && allows(pair.previous, key, txTimestamp); + } + + private static boolean allows(Generation generation, Object key, long txTimestamp) { + Long evictedAt = generation.evictions.get(key); + return evictedAt == null || txTimestamp > evictedAt; + } + + /** Returns the live pair, rotating first if the current window has elapsed. */ + private Generations rotated() { + Generations pair = generations; + // subtraction, never a comparison of absolute values: only the difference of two nanoTime + // readings is meaningful, and it stays correct across the counter wrapping + if (clock.getAsLong() - pair.current.start <= windowNanos) return pair; + synchronized (this) { + if (generations != pair) return generations; // another thread rotated, its pair is fresh + Generations rotated = new Generations(new Generation(clock.getAsLong()), pair.current); + generations = rotated; + return rotated; + } + } + + private static final class Generations { + final Generation current; + final Generation previous; + + Generations(Generation current, Generation previous) { + this.current = current; + this.previous = previous; + } + } + + private static final class Generation { + final long start; + final ConcurrentHashMap evictions = new ConcurrentHashMap<>(); + + Generation(long start) { + this.start = start; + } + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/EvictionGuardStats.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/EvictionGuardStats.java new file mode 100644 index 000000000000..7d0bf49e3f90 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/EvictionGuardStats.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.LongAdder; + +/** + * Per-region counters for {@link EvictionGuard} outcomes: puts stored, puts refused up front, and + * puts stored and then taken back. + * + *

The registry is static because its two ends cannot meet through Spring: the counting end is a + * Hibernate cache access strategy built deep inside the region factory with no access to any DI + * container, the reading end is a Micrometer binder in another module. A process-wide static keyed + * by region name is the only meeting point both ends share, and JVM scope is the right scope for + * diagnostics. {@link LongAdder} keeps increments contention-free; reads happen only on metrics + * scrapes. + * + * @author Morten Svanæs + */ +public final class EvictionGuardStats { + private static final ConcurrentHashMap REGISTRY = + new ConcurrentHashMap<>(); + + private final String regionName; + private final LongAdder storedPuts = new LongAdder(); + private final LongAdder refused = new LongAdder(); + private final LongAdder selfEvicted = new LongAdder(); + + private EvictionGuardStats(String regionName) { + this.regionName = regionName; + } + + /** Returns the counters for the given region, creating them once and reusing them after that. */ + public static EvictionGuardStats forRegion(String regionName) { + return REGISTRY.computeIfAbsent(regionName, EvictionGuardStats::new); + } + + /** + * Returns every region's counters keyed by region name, as an unmodifiable view of the live + * registry: regions registered later become visible through it, and the counters keep moving, + * which is what a metrics binder wants. + */ + public static Map all() { + return Collections.unmodifiableMap(REGISTRY); + } + + /** Records that the guard refused one stale {@code putFromLoad}. */ + public void countRefused() { + refused.increment(); + } + + /** + * Records that a reader took back a value it had already stored: a write landed between the guard + * check and the store, and the post-store re-check saw the newer eviction. Counts the reader + * undoing its own put, never writer bookkeeping. + */ + public void countSelfEvicted() { + selfEvicted.increment(); + } + + /** + * Records a {@code putFromLoad} that passed both guard checks and stayed in storage. The three + * counters partition the guarded puts: every call ends as exactly one of stored, refused or + * self-evicted. One branch is left out on purpose: when the superclass reports that it did not + * store, nothing is counted. That branch is unreachable on Hibernate 5.6, whose base {@code + * putFromLoad} always reports stored; if an upgrade changes that, the three counters stop adding + * up to the calls, and this is the branch to count next. + * + *

This counter is also a cross-check on the guard's reach: a NONSTRICT_READ_WRITE region is + * populated only through {@code putFromLoad}, so per region {@code ehcache_puts} must equal + * stored puts plus self-evictions. Drift above that sum means a write path bypasses the guard, + * and a path the guard does not see is a path it cannot bar. + */ + public void countStoredPut() { + storedPuts.increment(); + } + + public long getRefused() { + return refused.sum(); + } + + public long getSelfEvicted() { + return selfEvicted.sum(); + } + + public long getStoredPuts() { + return storedPuts.sum(); + } + + public String getRegionName() { + return regionName; + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedCollectionNonStrictReadWriteAccess.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedCollectionNonStrictReadWriteAccess.java new file mode 100644 index 000000000000..31d3a2d86a6c --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedCollectionNonStrictReadWriteAccess.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import java.util.Objects; +import org.hibernate.cache.cfg.spi.CollectionDataCachingConfig; +import org.hibernate.cache.spi.CacheKeysFactory; +import org.hibernate.cache.spi.DomainDataRegion; +import org.hibernate.cache.spi.access.SoftLock; +import org.hibernate.cache.spi.support.CollectionNonStrictReadWriteAccess; +import org.hibernate.cache.spi.support.DomainDataStorageAccess; +import org.hibernate.engine.spi.SharedSessionContractImplementor; + +/** + * NONSTRICT_READ_WRITE collection access that refuses stale late puts: the collection twin of + * {@link GuardedEntityNonStrictReadWriteAccess}, which states the two ordering rules, why every + * storage-reaching superclass path is overridden, the shared-guard-per-region requirement and the + * precedent. All of it applies here unchanged. + * + *

Collection specifics: there is no update or insert entry point at all. Hibernate signals a + * changed collection through {@code unlockItem} and a deleted one through {@code remove}, both + * guarded. + * + * @author Morten Svanæs + */ +public class GuardedCollectionNonStrictReadWriteAccess extends CollectionNonStrictReadWriteAccess { + private final EvictionGuard guard; + private final EvictionGuardStats stats; + + public GuardedCollectionNonStrictReadWriteAccess( + DomainDataRegion region, + CacheKeysFactory keysFactory, + DomainDataStorageAccess storageAccess, + CollectionDataCachingConfig config, + EvictionGuard guard) { + super(region, keysFactory, storageAccess, config); + this.guard = Objects.requireNonNull(guard, "guard"); + this.stats = EvictionGuardStats.forRegion(region.getName()); + } + + @Override + public void unlockItem(SharedSessionContractImplementor session, Object key, SoftLock lock) { + // record before evicting, so a concurrent put can never see the eviction without the record + guard.recordEviction(key, nextTimestamp()); + super.unlockItem(session, key, lock); + } + + @Override + public void remove(SharedSessionContractImplementor session, Object key) { + // record before evicting + guard.recordEviction(key, nextTimestamp()); + super.remove(session, key); + } + + @Override + public void removeAll(SharedSessionContractImplementor session) { + // record before clearing, same ordering rule applied to the whole region + guard.recordClearAll(nextTimestamp()); + super.removeAll(session); + } + + @Override + public void evict(Object key) { + // record before evicting + guard.recordEviction(key, nextTimestamp()); + super.evict(key); + } + + @Override + public void evictAll() { + // record before clearing + guard.recordClearAll(nextTimestamp()); + super.evictAll(); + } + + @Override + public void unlockRegion(SoftLock lock) { + // the superclass routes this to clearCache, which wipes storage without passing evictAll, + // so the record has to happen here. Reached after a bulk HQL update or delete commits. + guard.recordClearAll(nextTimestamp()); + super.unlockRegion(lock); + } + + @Override + public void destroy() { + // releasing the storage also drops its content, so record before it happens + guard.recordClearAll(nextTimestamp()); + super.destroy(); + } + + /** + * The five argument {@code putFromLoad} of the superclass delegates to this one, so guarding this + * overload guards both. + * + *

The take back on a failed re-check is a blind delete: it removes whatever sits under the + * key, which may be a fresher value that another reader stored between this store and this + * re-check. The cost of that is one extra cache miss, never staleness, so an unconditional delete + * is a better trade than reading the entry back to compare it. + */ + @Override + public boolean putFromLoad( + SharedSessionContractImplementor session, Object key, Object value, Object version) { + long txTimestamp = session.getCacheTransactionSynchronization().getCachingTimestamp(); + if (!guard.isPutAllowed(key, txTimestamp)) { + stats.countRefused(); + return false; + } + boolean stored = super.putFromLoad(session, key, value, version); + if (!stored) { + return false; + } + // Re-validate: a writer may have recorded and evicted between our check and our put. + // Writer order is record THEN evict, so if its evict raced past our put, the record + // is already visible here and we remove our own stale entry. + if (!guard.isPutAllowed(key, txTimestamp)) { + getStorageAccess().evictData(key); + stats.countSelfEvicted(); + return false; + } + stats.countStoredPut(); + return true; + } + + private long nextTimestamp() { + return getRegion().getRegionFactory().nextTimestamp(); + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedDomainDataRegion.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedDomainDataRegion.java new file mode 100644 index 000000000000..095286c3908a --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedDomainDataRegion.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import org.hibernate.cache.cfg.spi.CollectionDataCachingConfig; +import org.hibernate.cache.cfg.spi.DomainDataRegionBuildingContext; +import org.hibernate.cache.cfg.spi.DomainDataRegionConfig; +import org.hibernate.cache.cfg.spi.EntityDataCachingConfig; +import org.hibernate.cache.jcache.internal.JCacheDomainDataRegionImpl; +import org.hibernate.cache.spi.CacheKeysFactory; +import org.hibernate.cache.spi.access.AccessType; +import org.hibernate.cache.spi.access.CollectionDataAccess; +import org.hibernate.cache.spi.access.EntityDataAccess; +import org.hibernate.cache.spi.support.DomainDataStorageAccess; +import org.hibernate.cache.spi.support.RegionFactoryTemplate; + +/** + * JCache domain data region whose NONSTRICT_READ_WRITE entity and collection accesses are {@link + * GuardedEntityNonStrictReadWriteAccess} and {@link GuardedCollectionNonStrictReadWriteAccess}. + * Every other access type is left exactly as Hibernate builds it. + * + *

Extends {@link JCacheDomainDataRegionImpl} rather than {@code DomainDataRegionTemplate}: that + * is the region {@code JCacheRegionFactory} builds itself, and it downgrades a TRANSACTIONAL + * mapping to a warning where the plain template would throw. Natural-id access is not overridden: + * nothing in the NONSTRICT_READ_WRITE bucket uses it, and a guarded access with no caller would be + * untested code. + * + * @author Morten Svanæs + */ +public class GuardedDomainDataRegion extends JCacheDomainDataRegionImpl { // NOSONAR java:S110 + + /** + * One guard per region instance, shared by every access object the region generates: sibling + * accesses share one storage, so a clear recorded through one must bar stale puts arriving + * through the others. DHIS2 builds one SessionFactory, and closing it closes its CacheManager, so + * a guard and the storage it protects live and die together. + * + *

Created lazily on first use. Constructor assignment and a field initializer are both + * forbidden here: they run only after the superclass constructor has already generated every + * access object through {@code completeInstantiation}, which would hand out accesses holding a + * null guard. The unsynchronized lazy init is safe because Hibernate builds a region entirely on + * the bootstrap thread, and no access object is reachable by another thread before the build + * returns. + */ + private EvictionGuard guard; + + public GuardedDomainDataRegion( + DomainDataRegionConfig regionConfig, + RegionFactoryTemplate regionFactory, + DomainDataStorageAccess storageAccess, + CacheKeysFactory defaultKeysFactory, + DomainDataRegionBuildingContext buildingContext) { + super(regionConfig, regionFactory, storageAccess, defaultKeysFactory, buildingContext); + } + + @Override + protected EntityDataAccess generateNonStrictReadWriteEntityAccess( + EntityDataCachingConfig accessConfig) { + return new GuardedEntityNonStrictReadWriteAccess( + this, getEffectiveKeysFactory(), getCacheStorageAccess(), accessConfig, guard()); + } + + @Override + public CollectionDataAccess generateCollectionAccess(CollectionDataCachingConfig accessConfig) { + // the collection counterpart of generateNonStrictReadWriteEntityAccess is private in the + // template, so this switch is the only interception point. The configured access type is the + // one to test: the collection access object built for a NONSTRICT_READ_WRITE mapping reports + // READ_WRITE from its own getAccessType() in Hibernate 5.6. + if (accessConfig.getAccessType() == AccessType.NONSTRICT_READ_WRITE) { + return new GuardedCollectionNonStrictReadWriteAccess( + this, getEffectiveKeysFactory(), getCacheStorageAccess(), accessConfig, guard()); + } + return super.generateCollectionAccess(accessConfig); + } + + private EvictionGuard guard() { + if (guard == null) { + guard = new EvictionGuard(); + } + return guard; + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedEntityNonStrictReadWriteAccess.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedEntityNonStrictReadWriteAccess.java new file mode 100644 index 000000000000..c82d63de2e5b --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedEntityNonStrictReadWriteAccess.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import java.util.Objects; +import org.hibernate.cache.cfg.spi.EntityDataCachingConfig; +import org.hibernate.cache.spi.CacheKeysFactory; +import org.hibernate.cache.spi.DomainDataRegion; +import org.hibernate.cache.spi.access.SoftLock; +import org.hibernate.cache.spi.support.DomainDataStorageAccess; +import org.hibernate.cache.spi.support.EntityNonStrictReadWriteAccess; +import org.hibernate.engine.spi.SharedSessionContractImplementor; + +/** + * NONSTRICT_READ_WRITE entity access that refuses stale late puts. Plain NONSTRICT_READ_WRITE + * evicts on write and then accepts any {@code putFromLoad} that arrives afterwards, so a reader + * that loaded a row before a write and stores it after the write strands the pre-write value in the + * L2 cache until something else evicts that key. Two rules close that window: + * + *

    + *
  1. Writers record into the {@link EvictionGuard} BEFORE they touch storage, so an eviction + * visible in storage is already visible in the guard. + *
  2. Readers check the guard, put, then re-check and remove their own entry if the re-check + * fails, so a write landing between check and put cannot leave the reader's value behind. + *
+ * + *

Every superclass path that reaches storage is overridden, not only the ones current DHIS2 code + * calls: a path that evicts storage without recording first reopens the window. Two are easy to + * miss: {@code unlockRegion}, which the superclass routes straight to {@code clearCache} and which + * is reached after a bulk HQL update or delete commits, and {@code destroy}. Insert paths are + * deliberately not overridden: a freshly inserted row has no prior value to strand. + * + *

The guard's scope is the region: every access object of one {@link DomainDataRegion} must be + * given the same guard instance, because sibling accesses share one storage, and a clear recorded + * through one must bar stale puts arriving through the others. + * + *

The design is not novel, only missing from Hibernate's NONSTRICT implementation: Infinispan's + * NonStrictAccessDelegate and PutFromLoadValidator refuse the same put, Hibernate's + * UpdateTimestampsCache applies the same comparison to the query cache, and READ_WRITE gets the + * effect from SoftLock unlock timestamps. This class buys the guarantee without READ_WRITE's per + * key locking cost. + * + * @author Morten Svanæs + */ +public class GuardedEntityNonStrictReadWriteAccess extends EntityNonStrictReadWriteAccess { + private final EvictionGuard guard; + private final EvictionGuardStats stats; + + public GuardedEntityNonStrictReadWriteAccess( + DomainDataRegion region, + CacheKeysFactory keysFactory, + DomainDataStorageAccess storageAccess, + EntityDataCachingConfig config, + EvictionGuard guard) { + super(region, keysFactory, storageAccess, config); + this.guard = Objects.requireNonNull(guard, "guard"); + this.stats = EvictionGuardStats.forRegion(region.getName()); + } + + @Override + public boolean update( + SharedSessionContractImplementor session, + Object key, + Object value, + Object currentVersion, + Object previousVersion) { + // record before evicting, so a concurrent put can never see the eviction without the record + guard.recordEviction(key, nextTimestamp()); + return super.update(session, key, value, currentVersion, previousVersion); + } + + @Override + public void unlockItem(SharedSessionContractImplementor session, Object key, SoftLock lock) { + // record before evicting; this is also the path afterUpdate delegates to + guard.recordEviction(key, nextTimestamp()); + super.unlockItem(session, key, lock); + } + + @Override + public void remove(SharedSessionContractImplementor session, Object key) { + // record before evicting + guard.recordEviction(key, nextTimestamp()); + super.remove(session, key); + } + + @Override + public void removeAll(SharedSessionContractImplementor session) { + // record before clearing, same ordering rule applied to the whole region + guard.recordClearAll(nextTimestamp()); + super.removeAll(session); + } + + @Override + public void evict(Object key) { + // record before evicting + guard.recordEviction(key, nextTimestamp()); + super.evict(key); + } + + @Override + public void evictAll() { + // record before clearing + guard.recordClearAll(nextTimestamp()); + super.evictAll(); + } + + @Override + public void unlockRegion(SoftLock lock) { + // the superclass routes this to clearCache, which wipes storage without passing evictAll, + // so the record has to happen here. Reached after a bulk HQL update or delete commits. + guard.recordClearAll(nextTimestamp()); + super.unlockRegion(lock); + } + + @Override + public void destroy() { + // releasing the storage also drops its content, so record before it happens + guard.recordClearAll(nextTimestamp()); + super.destroy(); + } + + /** + * The five argument {@code putFromLoad} of the superclass delegates to this one, so guarding this + * overload guards both. + * + *

The take back on a failed re-check is a blind delete: it removes whatever sits under the + * key, which may be a fresher value that another reader stored between this store and this + * re-check. The cost of that is one extra cache miss, never staleness, so an unconditional delete + * is a better trade than reading the entry back to compare it. + */ + @Override + public boolean putFromLoad( + SharedSessionContractImplementor session, Object key, Object value, Object version) { + long txTimestamp = session.getCacheTransactionSynchronization().getCachingTimestamp(); + if (!guard.isPutAllowed(key, txTimestamp)) { + stats.countRefused(); + return false; + } + boolean stored = super.putFromLoad(session, key, value, version); + if (!stored) { + return false; + } + // Re-validate: a writer may have recorded and evicted between our check and our put. + // Writer order is record THEN evict, so if its evict raced past our put, the record + // is already visible here and we remove our own stale entry. + if (!guard.isPutAllowed(key, txTimestamp)) { + getStorageAccess().evictData(key); + stats.countSelfEvicted(); + return false; + } + stats.countStoredPut(); + return true; + } + + private long nextTimestamp() { + return getRegion().getRegionFactory().nextTimestamp(); + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedJCacheRegionFactory.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedJCacheRegionFactory.java new file mode 100644 index 000000000000..dfc70dcfa8d1 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/cache/guard/GuardedJCacheRegionFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import org.hibernate.cache.cfg.spi.DomainDataRegionBuildingContext; +import org.hibernate.cache.cfg.spi.DomainDataRegionConfig; +import org.hibernate.cache.jcache.internal.JCacheRegionFactory; +import org.hibernate.cache.spi.DomainDataRegion; + +/** + * JCache region factory that builds {@link GuardedDomainDataRegion} instead of Hibernate's own + * region, so NONSTRICT_READ_WRITE entities and collections refuse stale late puts. See {@link + * GuardedEntityNonStrictReadWriteAccess} for the race and the ordering rules, {@link EvictionGuard} + * for the bookkeeping. + * + *

Hibernate instantiates this class reflectively from {@code + * hibernate.cache.region.factory_class}, so it must stay public with a public no-argument + * constructor. Only the domain data region build is replaced; query results and timestamps regions + * stay inherited. + * + * @author Morten Svanæs + */ +public class GuardedJCacheRegionFactory extends JCacheRegionFactory { + + /** + * Mirrors {@code JCacheRegionFactory.buildDomainDataRegion} with the region type swapped. {@code + * getImplicitCacheKeysFactory()} returns exactly the keys factory the superclass passes to its + * own region, and the {@code verifyStarted()} call is the guard {@code RegionFactoryTemplate} + * puts in front of every region build. + */ + @Override + public DomainDataRegion buildDomainDataRegion( + DomainDataRegionConfig regionConfig, DomainDataRegionBuildingContext buildingContext) { + verifyStarted(); + return new GuardedDomainDataRegion( + regionConfig, + this, + createDomainDataStorageAccess(regionConfig, buildingContext), + getImplicitCacheKeysFactory(), + buildingContext); + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/config/HibernateConfig.java b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/config/HibernateConfig.java index 9b8dfefd1a7f..a77f5fa400c8 100644 --- a/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/config/HibernateConfig.java +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/java/org/hisp/dhis/config/HibernateConfig.java @@ -45,11 +45,11 @@ import org.hibernate.SessionFactory; import org.hibernate.cache.jcache.ConfigSettings; import org.hibernate.cache.jcache.MissingCacheStrategy; -import org.hibernate.cache.jcache.internal.JCacheRegionFactory; import org.hibernate.cfg.AvailableSettings; import org.hibernate.jpa.HibernatePersistenceProvider; import org.hibernate.tool.schema.Action; import org.hisp.dhis.cache.DefaultHibernateCacheManager; +import org.hisp.dhis.cache.guard.GuardedJCacheRegionFactory; import org.hisp.dhis.dbms.DbmsManager; import org.hisp.dhis.dbms.HibernateDbmsManager; import org.hisp.dhis.external.conf.ConfigurationKey; @@ -166,7 +166,8 @@ public static Properties getAdditionalProperties(DhisConfigurationProvider dhisC if (dhisConfig.isEnabled(USE_SECOND_LEVEL_CACHE)) { properties.put(AvailableSettings.USE_SECOND_LEVEL_CACHE, "true"); - properties.put(AvailableSettings.CACHE_REGION_FACTORY, JCacheRegionFactory.class.getName()); + properties.put( + AvailableSettings.CACHE_REGION_FACTORY, GuardedJCacheRegionFactory.class.getName()); // Normalize to true/false: Hibernate parses this value itself and does not understand // the on/off variants allowed in dhis.conf. properties.put( @@ -177,7 +178,7 @@ public static Properties getAdditionalProperties(DhisConfigurationProvider dhisC // Specify the location of the Ehcache 3 configuration file String configFile = dhisConfig.getProperty(CACHE_EHCACHE_CONFIG_FILE); if (!configFile.isBlank()) { - properties.put(ConfigSettings.CONFIG_URI, configFile); + properties.put(ConfigSettings.CONFIG_URI, normalizeEhcacheConfigLocation(configFile)); } } else { // Explicitly disable both caches. Without this, Hibernate auto-enables the second level @@ -197,4 +198,25 @@ public static Properties getAdditionalProperties(DhisConfigurationProvider dhisC return properties; } + + /** + * Normalizes the {@code cache.ehcache.config.file} value so Hibernate can resolve it. + * + *

Hibernate's ClassLoaderService only understands the nonstandard {@code classpath://} scheme: + * it strips that exact prefix and resolves the rest against the classpath, while the common + * {@code classpath:} spelling (also the ConfigurationKey default) is passed to the classloader + * verbatim, never resolves, and fails the SessionFactory boot with "Couldn't load URI". Both + * spellings are therefore stripped here; a bare resource name resolves fine. Other values ({@code + * file:} URLs, absolute paths) are passed through untouched. + */ + static String normalizeEhcacheConfigLocation(String location) { + String value = location.strip(); + if (value.regionMatches(true, 0, "classpath://", 0, 12)) { + return value.substring(12); + } + if (value.regionMatches(true, 0, "classpath:", 0, 10)) { + return value.substring(10); + } + return value; + } } diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/main/resources/ehcache.xml b/dhis-2/dhis-support/dhis-support-hibernate/src/main/resources/ehcache.xml index ee483c110010..09bfbcbae277 100644 --- a/dhis-2/dhis-support/dhis-support-hibernate/src/main/resources/ehcache.xml +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/main/resources/ehcache.xml @@ -16,9 +16,12 @@ - - - 21600 + + 3600 @@ -43,15 +46,140 @@ + + + 3600 + 200000 + + + 3600 + 100000 + + + 3600 + 100000 + + + 3600 + 20000 + + + 3600 + 200000 + + + 3600 + 20000 + + + 3600 + 500000 + + + 3600 + 100000 + + + 3600 + 50000 + + + 3600 + 20000 + + + 3600 + 10000 + + + 3600 + 1000 + + + 3600 + 100000 + + + + 3600 + 200000 + + + 3600 + 20000 + + + 3600 + 1000 + + + 3600 + 20000 + + + + + + 3600 + 200000 + + + 3600 + 20000 + + + 3600 + 20000 + + + 3600 + 20000 + + + 3600 + 500000 + + + 3600 + 50000 + + + 3600 + 20000 + + + 3600 + 20000 + + + 3600 + 100000 + + + 3600 + 100000 + + + 3600 + 100000 + + diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/EvictionGuardStatsTest.java b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/EvictionGuardStatsTest.java new file mode 100644 index 000000000000..fcc4662cee96 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/EvictionGuardStatsTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link EvictionGuardStats}. + * + * @author Morten Svanæs + */ +class EvictionGuardStatsTest { + @Test + void forRegionIsIdempotentAndCounts() { + EvictionGuardStats stats = EvictionGuardStats.forRegion("r1"); + assertSame(stats, EvictionGuardStats.forRegion("r1")); + stats.countRefused(); + stats.countRefused(); + stats.countSelfEvicted(); + stats.countStoredPut(); + stats.countStoredPut(); + stats.countStoredPut(); + assertEquals(2, stats.getRefused()); + assertEquals(1, stats.getSelfEvicted()); + assertEquals(3, stats.getStoredPuts()); + assertTrue(EvictionGuardStats.all().containsKey("r1")); + } + + @Test + void statsAreKeptPerRegion() { + EvictionGuardStats first = EvictionGuardStats.forRegion("perRegionA"); + EvictionGuardStats second = EvictionGuardStats.forRegion("perRegionB"); + first.countRefused(); + assertEquals(1, first.getRefused()); + assertEquals(0, second.getRefused()); + assertEquals("perRegionA", first.getRegionName()); + assertEquals("perRegionB", second.getRegionName()); + } + + @Test + void allIsUnmodifiable() { + EvictionGuardStats stats = EvictionGuardStats.forRegion("unmodifiable"); + Map all = EvictionGuardStats.all(); + assertSame(stats, all.get("unmodifiable")); + assertThrows(UnsupportedOperationException.class, () -> all.remove("unmodifiable")); + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/EvictionGuardTest.java b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/EvictionGuardTest.java new file mode 100644 index 000000000000..eadfea39a961 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/EvictionGuardTest.java @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +/** + * Tests for {@link EvictionGuard}. + * + * @author Morten Svanæs + */ +class EvictionGuardTest { + @Test + void putAllowedWhenNoEvictionRecorded() { + EvictionGuard guard = new EvictionGuard(); + assertTrue(guard.isPutAllowed("k", 100L)); + } + + @ParameterizedTest + @CsvSource({ + "100, 100, false", // tx at eviction time: refuse (inclusive bound) + "100, 99, false", // tx before eviction: refuse + "100, 101, true", // tx after eviction: allow + }) + void perKeyEvictionGatesPut(long evictTs, long txTs, boolean allowed) { + EvictionGuard guard = new EvictionGuard(); + guard.recordEviction("k", evictTs); + assertEquals(allowed, guard.isPutAllowed("k", txTs)); + } + + @Test + void laterEvictionWinsOverEarlier() { + EvictionGuard guard = new EvictionGuard(); + guard.recordEviction("k", 200L); + guard.recordEviction("k", 100L); // out-of-order record must not lower the bar + assertFalse(guard.isPutAllowed("k", 150L)); + } + + @Test + void otherKeysUnaffected() { + EvictionGuard guard = new EvictionGuard(); + guard.recordEviction("k", 100L); + assertTrue(guard.isPutAllowed("other", 50L)); + } + + @ParameterizedTest + @CsvSource({"100, 100, false", "100, 99, false", "100, 101, true"}) + void regionClearGatesEveryKey(long clearTs, long txTs, boolean allowed) { + EvictionGuard guard = new EvictionGuard(); + guard.recordClearAll(clearTs); + assertEquals(allowed, guard.isPutAllowed("anything", txTs)); + } + + @Test + void rotationKeepsPreviousGenerationVisible() { + AtomicLong clock = new AtomicLong(0); + EvictionGuard guard = new EvictionGuard(clock::get, 1000L); // window=1000 clock ticks + guard.recordEviction("k", 100L); + clock.set(1500); // one rotation: k moves to previous gen + guard.recordEviction("other", 200L); // write path triggers rotation + assertFalse(guard.isPutAllowed("k", 50L), "previous generation must still refuse"); + clock.set(3000); // two windows later: k fully expired + guard.recordEviction("other2", 300L); + assertTrue(guard.isPutAllowed("k", 50L), "expired entries no longer refuse"); + } + + @ParameterizedTest + @CsvSource({ + "200, 100, 150, false", // out-of-order older clear must not lower the region bar + "100, 200, 150, false", // in-order clears raise it + "200, 100, 201, true", // a tx newer than the highest clear is still allowed + }) + void laterClearAllWinsOverEarlier( + long firstClearTs, long secondClearTs, long txTs, boolean allowed) { + EvictionGuard guard = new EvictionGuard(); + guard.recordClearAll(firstClearTs); + guard.recordClearAll(secondClearTs); + assertEquals(allowed, guard.isPutAllowed("anything", txTs)); + } + + @ParameterizedTest + @CsvSource({ + "200, 100, 150, 201", // the region clear is the higher bar and refuses on its own + "200, 300, 250, 350", // the per-key eviction is the higher bar, the older clear never lowers it + "200, 200, 200, 201", // both bars at the same time: the inclusive bound refuses a tx on it + }) + void putMustClearBothTheRegionBarAndThePerKeyBar( + long clearTs, long evictTs, long refusedTxTs, long allowedTxTs) { + EvictionGuard guard = new EvictionGuard(); + // the eviction is recorded first on purpose: a clear that wiped the per-key generations instead + // of only raising the region bar would let the second case's tx 250 through + guard.recordEviction("k", evictTs); + guard.recordClearAll(clearTs); + assertFalse(guard.isPutAllowed("k", refusedTxTs), "a tx below either bar must be refused"); + assertTrue(guard.isPutAllowed("k", allowedTxTs), "a tx above both bars must be allowed"); + } + + @Test + void rotationOnEveryCallNeverHidesYoungEviction() { + // a clock that jumps a full window on every read forces a rotation inside every guard call, so + // each call sees the generation pair one rotation later than the previous call did + AtomicLong clock = new AtomicLong(0); + EvictionGuard guard = new EvictionGuard(() -> clock.addAndGet(1001L), 1000L); + guard.recordEviction("k", 100L); + assertFalse(guard.isPutAllowed("k", 50L), "a rotation on the read path must not hide k"); + assertTrue(guard.isPutAllowed("k", 50L), "after a second rotation k has expired"); + } + + @Test + void oneConcurrentRotationNeverHidesAYoungEviction() throws Exception { + long window = 1000L; + int rounds = 500; + AtomicLong clock = new AtomicLong(0); + EvictionGuard guard = new EvictionGuard(clock::get, window); + CyclicBarrier barrier = new CyclicBarrier(2); + AtomicReference rotatorFailure = new AtomicReference<>(); + + Thread rotator = + new Thread( + () -> { + try { + for (int round = 0; round < rounds; round++) { + barrier.await(); + clock.addAndGet(window + 1); // exactly one rotation per round + guard.recordEviction("rotator-" + round, 1L); + barrier.await(); + } + } catch (InterruptedException e) { + // the recording thread finished or failed, nothing left to rotate + } catch (Throwable t) { + rotatorFailure.set(t); + } + }); + rotator.setDaemon(true); + rotator.start(); + try { + for (int round = 0; round < rounds; round++) { + barrier.await(); + String key = "k" + round; + guard.recordEviction(key, 100L); + // at most one rotation can interleave per round, so the record is younger than one full + // window and must still be found in one of the two generations + assertFalse(guard.isPutAllowed(key, 50L), "a racing rotation hid the record for " + key); + barrier.await(); + } + } finally { + rotator.interrupt(); + rotator.join(5000); + } + assertNull(rotatorFailure.get(), "rotator thread failed"); + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedAccessSuperclassSurfaceTest.java b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedAccessSuperclassSurfaceTest.java new file mode 100644 index 000000000000..70094109b024 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedAccessSuperclassSurfaceTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import org.hibernate.cache.spi.support.AbstractCachedDomainDataAccess; +import org.hibernate.cache.spi.support.AbstractCollectionDataAccess; +import org.hibernate.cache.spi.support.AbstractEntityDataAccess; +import org.hibernate.cache.spi.support.CollectionNonStrictReadWriteAccess; +import org.hibernate.cache.spi.support.EntityNonStrictReadWriteAccess; +import org.junit.jupiter.api.Test; + +/** + * Pins the declared method surface of the five Hibernate superclasses of {@link + * GuardedEntityNonStrictReadWriteAccess} and {@link GuardedCollectionNonStrictReadWriteAccess}. + * + *

The guard's invariant is that every inherited path which writes or clears region storage is + * overridden and records into the {@link EvictionGuard} first. If a Hibernate upgrade adds a + * storage-touching method to a superclass, the subclasses still compile, every other test passes, + * and the stale-put window silently reopens for that path. This test turns that one silent failure + * mode into a loud one. + * + *

A failure here means the inherited surface changed, which is not by itself a defect: read the + * new entry, decide whether it can reach storage, extend the guarded subclasses if it can, and only + * then update the expected set. Signatures use simple type names, enough to separate the existing + * overloads. + * + * @author Morten Svanæs + */ +class GuardedAccessSuperclassSurfaceTest { + + @Test + void abstractCachedDomainDataAccessSurfaceIsUnchanged() { + assertSurface( + AbstractCachedDomainDataAccess.class, + "getRegion()", + "getStorageAccess()", + "clearCache()", + "contains(Object)", + "get(SharedSessionContractImplementor,Object)", + "putFromLoad(SharedSessionContractImplementor,Object,Object,Object)", + "putFromLoad(SharedSessionContractImplementor,Object,Object,Object,boolean)", + "lockRegion()", + "unlockRegion(SoftLock)", + "remove(SharedSessionContractImplementor,Object)", + "removeAll(SharedSessionContractImplementor)", + "evict(Object)", + "evictAll()", + "destroy()"); + } + + @Test + void abstractEntityDataAccessSurfaceIsUnchanged() { + assertSurface( + AbstractEntityDataAccess.class, + "generateCacheKey(Object,EntityPersister,SessionFactoryImplementor,String)", + "getCacheKeyId(Object)", + "lockRegion()", + "unlockRegion(SoftLock)", + "lockItem(SharedSessionContractImplementor,Object,Object)", + "unlockItem(SharedSessionContractImplementor,Object,SoftLock)"); + } + + @Test + void abstractCollectionDataAccessSurfaceIsUnchanged() { + assertSurface( + AbstractCollectionDataAccess.class, + "generateCacheKey(Object,CollectionPersister,SessionFactoryImplementor,String)", + "getCacheKeyId(Object)", + "lockItem(SharedSessionContractImplementor,Object,Object)", + "unlockItem(SharedSessionContractImplementor,Object,SoftLock)", + "lockRegion()", + "unlockRegion(SoftLock)"); + } + + @Test + void entityNonStrictReadWriteAccessSurfaceIsUnchanged() { + assertSurface( + EntityNonStrictReadWriteAccess.class, + "getAccessType()", + "insert(SharedSessionContractImplementor,Object,Object,Object)", + "afterInsert(SharedSessionContractImplementor,Object,Object,Object)", + "update(SharedSessionContractImplementor,Object,Object,Object,Object)", + "afterUpdate(SharedSessionContractImplementor,Object,Object,Object,Object,SoftLock)", + "unlockItem(SharedSessionContractImplementor,Object,SoftLock)", + "remove(SharedSessionContractImplementor,Object)"); + } + + @Test + void collectionNonStrictReadWriteAccessSurfaceIsUnchanged() { + assertSurface( + CollectionNonStrictReadWriteAccess.class, + "getAccessType()", + "unlockItem(SharedSessionContractImplementor,Object,SoftLock)"); + } + + private static void assertSurface(Class type, String... expected) { + assertEquals( + new TreeSet<>(Set.of(expected)), + declaredSurfaceOf(type), + () -> + "the declared method surface of " + + type.getName() + + " changed; see this test class's javadoc before updating the expected set"); + } + + /** The methods a subclass in another package can override: the public and protected ones. */ + private static Set declaredSurfaceOf(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .filter(method -> !method.isSynthetic()) + .filter( + method -> + Modifier.isPublic(method.getModifiers()) + || Modifier.isProtected(method.getModifiers())) + .map(GuardedAccessSuperclassSurfaceTest::signatureOf) + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static String signatureOf(Method method) { + return Arrays.stream(method.getParameterTypes()) + .map(Class::getSimpleName) + .collect(Collectors.joining(",", method.getName() + "(", ")")); + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedNonStrictCacheEffectTest.java b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedNonStrictCacheEffectTest.java new file mode 100644 index 000000000000..e8929e586e04 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedNonStrictCacheEffectTest.java @@ -0,0 +1,405 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import static org.hisp.dhis.external.conf.ConfigurationKey.CACHE_EHCACHE_CONFIG_FILE; +import static org.hisp.dhis.external.conf.ConfigurationKey.USE_QUERY_CACHE; +import static org.hisp.dhis.external.conf.ConfigurationKey.USE_SECOND_LEVEL_CACHE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.persistence.Cacheable; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.HashSet; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cache.spi.access.CollectionDataAccess; +import org.hibernate.cache.spi.access.EntityDataAccess; +import org.hibernate.cache.spi.support.AbstractReadWriteAccess; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.persister.collection.CollectionPersister; +import org.hibernate.persister.entity.EntityPersister; +import org.hisp.dhis.config.HibernateConfig; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.hibernate.dialect.DhisH2Dialect; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Boot-level proof that the eviction guard is wired and refuses a stale late put, asserted against + * a real {@link SessionFactory} over H2 and a real ehcache-backed CacheManager built from {@link + * HibernateConfig#getAdditionalProperties(DhisConfigurationProvider)}. Everything below the test is + * production wiring. + * + *

Wiring assertions: a NONSTRICT_READ_WRITE mapping resolves to the guarded access classes while + * a READ_WRITE mapping in the same SessionFactory stays on Hibernate's own, so dropping the region + * factory, the region or the NONSTRICT dispatch fails here instead of silently reverting. + * Behavioural assertion: the stranding interleaving is scripted, not raced. A reader session opened + * before the write keeps its caching timestamp older than the eviction, and its late {@code + * putFromLoad} is replayed directly on the {@link EntityDataAccess} SPI, so the only reason the put + * is refused is the guard's timestamp comparison. {@link GuardedNonStrictInterleavingTest} covers + * every eviction path against a stub; this test proves the rules survive real wiring. + * + *

{@link NonstrictEntity} (NONSTRICT_READ_WRITE, owns a NONSTRICT_READ_WRITE collection) and its + * element type {@link ReadWriteEntity} (the READ_WRITE control) share one SessionFactory, so + * guarding is shown to follow the mapping, not the region factory. Region names are the nested + * class FQNs, unique to this test, because the {@link EvictionGuardStats} registry has no reset. + * {@code dhisConfig} and {@code buildSessionFactory} are copied from {@code + * org.hisp.dhis.config.HibernateCacheEffectTest} (private there) minus its statement inspector and + * statistics settings. Isolation between test methods comes from {@code sessionFactory.close()}, + * which closes the CacheManager and with it every region's storage. + * + * @author Morten Svanæs + */ +class GuardedNonStrictCacheEffectTest { + + private static final AtomicInteger DB_SEQ = new AtomicInteger(); + + private static final long ENTITY_ID = 1L; + + private static final String NONSTRICT_ENTITY_REGION = NonstrictEntity.class.getName(); + + private static final String NONSTRICT_COLLECTION_ROLE = + NonstrictEntity.class.getName() + ".children"; + + private static final String NAME_BEFORE_WRITE = "before the write"; + + private static final String NAME_AFTER_WRITE = "written by W"; + + /** + * Stands in for the cache entry the reader's load would have put. The guard refuses before the + * value is looked at, so any non-null value is enough, and a value that is obviously not a cache + * entry makes it plain that the refusal cannot depend on it. + */ + private static final String STALE_PLACEHOLDER = "value read before the write"; + + private SessionFactory sessionFactory; + + @BeforeEach + void setUp() { + sessionFactory = buildSessionFactory(dhisConfig("true", "false", "classpath:ehcache.xml")); + } + + @AfterEach + void tearDown() { + if (sessionFactory != null) { + sessionFactory.close(); + sessionFactory = null; + } + } + + @Test + void nonstrictEntityRegionGetsGuardedAccess() { + EntityDataAccess access = entityAccess(NonstrictEntity.class); + + assertInstanceOf(GuardedEntityNonStrictReadWriteAccess.class, access); + assertEquals( + NONSTRICT_ENTITY_REGION, + access.getRegion().getName(), + "the region name is the key of this test's guard counters and must stay unique to it"); + } + + @Test + void nonstrictCollectionRegionGetsGuardedAccess() { + CollectionPersister persister = + sessionFactoryImplementor().getMetamodel().collectionPersister(NONSTRICT_COLLECTION_ROLE); + CollectionDataAccess access = persister.getCacheAccessStrategy(); + assertNotNull( + access, "the NONSTRICT collection mapping must be cached: " + persister.getRole()); + + // deliberately not an assertion on access.getAccessType(): the collection access built for a + // NONSTRICT_READ_WRITE mapping reports READ_WRITE in Hibernate 5.6, which is why the region + // dispatches on the configured access type instead + assertInstanceOf(GuardedCollectionNonStrictReadWriteAccess.class, access); + } + + @Test + void readWriteRegionStaysStock() { + EntityDataAccess access = entityAccess(ReadWriteEntity.class); + + assertInstanceOf(AbstractReadWriteAccess.class, access); + assertFalse( + access instanceof GuardedEntityNonStrictReadWriteAccess, + "a READ_WRITE mapping must keep Hibernate's own access, it already bars stale puts with a" + + " SoftLock"); + } + + /** + * Ivo's interleaving, executed step by step against real storage: a reader whose transaction + * started before a write completes its load after that write, and its {@code putFromLoad} would + * strand the pre write value in the region under plain NONSTRICT_READ_WRITE. The guard must + * refuse it, and must refuse it without wedging the region for later readers. + */ + @Test + void scriptedStrandingLosesAgainstRealStorage() { + persistEntity(); + EntityPersister persister = entityPersister(NonstrictEntity.class); + EntityDataAccess access = persister.getCacheAccessStrategy(); + EvictionGuardStats stats = EvictionGuardStats.forRegion(access.getRegion().getName()); + // the counters are process wide and never reset, and every test method builds a new + // SessionFactory over the same region name, so only the delta over this method is deterministic + long refusedBefore = stats.getRefused(); + long selfEvictedBefore = stats.getSelfEvicted(); + + // R opens before the write and stays open across it, so its caching timestamp keeps predating + // the eviction. That is the whole setup: no thread and no sleep is involved. + try (Session reader = sessionFactory.openSession()) { + SharedSessionContractImplementor readerSession = (SharedSessionContractImplementor) reader; + long readerTimestamp = cachingTimestamp(readerSession); + assertEquals(NAME_BEFORE_WRITE, loadName(reader), "R must read the pre write row"); + assertTrue( + cachedEntity(), + "R's own load must populate the region, so the stranding scenario has something to" + + " strand"); + + updateEntityInNewSession(); + + assertFalse( + cachedEntity(), + "the write must evict the entity, which is plain NONSTRICT_READ_WRITE behaviour and the" + + " precondition of the stale put"); + assertEquals( + readerTimestamp, + cachingTimestamp(readerSession), + "holding R open must keep its pre write caching timestamp"); + + Object cacheKey = + access.generateCacheKey(ENTITY_ID, persister, sessionFactoryImplementor(), null); + // R's late put, replayed on the SPI exactly as R's load would have completed it after the + // write landed + assertFalse( + access.putFromLoad(readerSession, cacheKey, STALE_PLACEHOLDER, null), + "a put from a transaction older than the eviction must be refused"); + assertFalse(access.contains(cacheKey), "the refused value must not reach region storage"); + assertFalse(cachedEntity(), "the region must still hold nothing for the key"); + assertEquals( + 1, stats.getRefused() - refusedBefore, "the refusal must be counted for the region"); + assertEquals( + 0, + stats.getSelfEvicted() - selfEvictedBefore, + "the up front check must refuse, so no value was stored and none had to be taken back"); + } + + // and the refusal must not wedge the region: a transaction that starts after the write caches + // the written value again + try (Session fresh = sessionFactory.openSession()) { + assertEquals(NAME_AFTER_WRITE, loadName(fresh), "a fresh load must see the written value"); + } + assertTrue( + cachedEntity(), + "a put from a transaction newer than the eviction must repopulate the region"); + } + + // ------------------------------------------------------------------------- + // Test plumbing + // ------------------------------------------------------------------------- + + /** Copied from the private helper of {@code HibernateCacheEffectTest}. */ + private static DhisConfigurationProvider dhisConfig( + String secondLevelCache, String queryCache, String ehcacheConfigFile) { + DhisConfigurationProvider dhisConfig = mock(DhisConfigurationProvider.class); + when(dhisConfig.isEnabled(any())).thenCallRealMethod(); + when(dhisConfig.getProperty(USE_SECOND_LEVEL_CACHE)).thenReturn(secondLevelCache); + when(dhisConfig.getProperty(USE_QUERY_CACHE)).thenReturn(queryCache); + when(dhisConfig.getProperty(CACHE_EHCACHE_CONFIG_FILE)).thenReturn(ehcacheConfigFile); + return dhisConfig; + } + + /** + * Copied from the private helper of {@code HibernateCacheEffectTest}, minus its statement + * inspector and statistics settings, which nothing here asserts on. + */ + private SessionFactory buildSessionFactory(DhisConfigurationProvider dhisConfig) { + Properties properties = HibernateConfig.getAdditionalProperties(dhisConfig); + + StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder(); + properties.forEach((key, value) -> registryBuilder.applySetting((String) key, value)); + registryBuilder.applySetting(AvailableSettings.DIALECT, DhisH2Dialect.class.getName()); + registryBuilder.applySetting(AvailableSettings.DRIVER, "org.h2.Driver"); + registryBuilder.applySetting( + AvailableSettings.URL, + "jdbc:h2:mem:guarded-nonstrict-" + DB_SEQ.incrementAndGet() + ";DB_CLOSE_DELAY=-1"); + registryBuilder.applySetting(AvailableSettings.USER, "sa"); + registryBuilder.applySetting(AvailableSettings.PASS, ""); + // The DHIS2 schema is owned by Flyway, but this test schema only exists in memory + registryBuilder.applySetting(AvailableSettings.HBM2DDL_AUTO, "create-drop"); + + return new MetadataSources(registryBuilder.build()) + .addAnnotatedClass(NonstrictEntity.class) + .addAnnotatedClass(ReadWriteEntity.class) + .buildMetadata() + .buildSessionFactory(); + } + + private SessionFactoryImplementor sessionFactoryImplementor() { + return (SessionFactoryImplementor) sessionFactory; + } + + private EntityPersister entityPersister(Class entityClass) { + return sessionFactoryImplementor().getMetamodel().entityPersister(entityClass); + } + + private EntityDataAccess entityAccess(Class entityClass) { + EntityPersister persister = entityPersister(entityClass); + EntityDataAccess access = persister.getCacheAccessStrategy(); + assertNotNull(access, "the mapping must be cached: " + persister.getEntityName()); + return access; + } + + private static long cachingTimestamp(SharedSessionContractImplementor session) { + return session.getCacheTransactionSynchronization().getCachingTimestamp(); + } + + /** Whether the real region storage currently holds the test entity. */ + private boolean cachedEntity() { + return sessionFactory.getCache().containsEntity(NonstrictEntity.class, ENTITY_ID); + } + + private void persistEntity() { + try (Session session = sessionFactory.openSession()) { + Transaction transaction = session.beginTransaction(); + session.persist(new NonstrictEntity(ENTITY_ID, NAME_BEFORE_WRITE)); + transaction.commit(); + } + } + + /** Writer W: a real update in its own transaction, so the real eviction paths fire. */ + private void updateEntityInNewSession() { + try (Session session = sessionFactory.openSession()) { + Transaction transaction = session.beginTransaction(); + NonstrictEntity entity = session.get(NonstrictEntity.class, ENTITY_ID); + assertNotNull(entity); + entity.setName(NAME_AFTER_WRITE); + transaction.commit(); + } + } + + private static String loadName(Session session) { + NonstrictEntity entity = session.get(NonstrictEntity.class, ENTITY_ID); + assertNotNull(entity); + return entity.getName(); + } + + /** + * The guarded case: NONSTRICT_READ_WRITE on the entity and on its cached collection, the mapping + * shape this whole change exists for. + */ + @Entity + @Table(name = "t5_nonstrict_entity") + @Cacheable + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + public static class NonstrictEntity { + + @Id private Long id; + + @Column private String name; + + @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinColumn(name = "parent_id") + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + private Set children = new HashSet<>(); + + public NonstrictEntity() {} + + public NonstrictEntity(Long id, String name) { + this.id = id; + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Set getChildren() { + return children; + } + } + + /** + * The control: READ_WRITE in the same SessionFactory as the guarded mapping, so the guarding is + * shown to follow the mapping rather than the region factory. It doubles as the element type of + * the guarded collection, which is why one region of that collection's own SessionFactory is + * guarded while the element region beside it is not. + */ + @Entity + @Table(name = "t5_read_write_entity") + @Cacheable + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) + public static class ReadWriteEntity { + + @Id private Long id; + + @Column private String name; + + public ReadWriteEntity() { + // JPA requires a public no-arg constructor + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedNonStrictInterleavingTest.java b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedNonStrictInterleavingTest.java new file mode 100644 index 000000000000..78d7967180da --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/cache/guard/GuardedNonStrictInterleavingTest.java @@ -0,0 +1,472 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache.guard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.hibernate.cache.cfg.spi.CollectionDataCachingConfig; +import org.hibernate.cache.cfg.spi.EntityDataCachingConfig; +import org.hibernate.cache.spi.CacheKeysFactory; +import org.hibernate.cache.spi.CacheTransactionSynchronization; +import org.hibernate.cache.spi.DomainDataRegion; +import org.hibernate.cache.spi.RegionFactory; +import org.hibernate.cache.spi.support.DomainDataStorageAccess; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Deterministic interleaving tests for {@link GuardedEntityNonStrictReadWriteAccess} and {@link + * GuardedCollectionNonStrictReadWriteAccess}: no threads and no timing. Schedules run step by step + * on the test thread, so the outcome asserts the guard's ordering rules, not a lucky run. + * + *

Cache timestamps are scripted through a mocked {@link RegionFactory} over an {@link + * AtomicLong}; reader transactions get theirs from a mocked {@link + * CacheTransactionSynchronization}. Storage is a {@link HashMap} behind a {@link + * DomainDataStorageAccess} stub whose put can run a callback first, which simulates a writer + * landing mid-put without a race. The guard is created by the test and injected, mirroring one + * guard per region. Every test uses its own {@link EvictionGuardStats} region name: the registry is + * process-wide and has no reset. + * + * @author Morten Svanæs + */ +class GuardedNonStrictInterleavingTest { + private static final Object KEY = "Entity#1"; + private static final Object OTHER_KEY = "Entity#2"; + private static final String STALE = "value read before the write"; + private static final String FRESH = "value read after the write"; + private static final String WRITTEN = "value written"; + + /** + * Steps of an interleaving: {@code W1} is the writer's update (records the eviction and evicts), + * {@code C} is the writer's post commit {@code unlockItem}, {@code P} is the reader's {@code + * putFromLoad} carrying a transaction timestamp captured before {@code W1}. + */ + enum Step { + W1, + C, + P + } + + /** One writer entry point, so every overridden eviction path can be asserted with one test. */ + private interface WriterPath { + void run( + GuardedEntityNonStrictReadWriteAccess access, SharedSessionContractImplementor session); + } + + /** Source of Hibernate cache timestamps, shared by the region factory and the test sessions. */ + private final AtomicLong cacheClock = new AtomicLong(1000); + + private final MapStorage storage = new MapStorage(); + + /** One guard per region, created here the way the region creates it in production. */ + private final EvictionGuard guard = new EvictionGuard(); + + static Stream schedules() { + return Stream.of( + // put after both evicts: refused up front, the guard already knows about the write + arguments(List.of(Step.W1, Step.C, Step.P), 1L, 0L), + // put between the two evicts: still refused, the first evict was already recorded + arguments(List.of(Step.W1, Step.P, Step.C), 1L, 0L), + // put before the write: stored, then removed by the write's own evicts + arguments(List.of(Step.P, Step.W1, Step.C), 0L, 1L)); + } + + @ParameterizedTest + @MethodSource("schedules") + void stalePutNeverSurvives(List schedule, long expectedRefused, long expectedStoredPuts) { + String regionName = + "t3-schedule-" + schedule.stream().map(Enum::name).collect(Collectors.joining("-")); + GuardedEntityNonStrictReadWriteAccess access = entityAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + // the reader's transaction started before any of the writer's steps + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + for (Step step : schedule) { + switch (step) { + case W1 -> access.update(writer, KEY, WRITTEN, null, null); + case C -> access.unlockItem(writer, KEY, null); + case P -> access.putFromLoad(reader, KEY, STALE, null); + } + } + + assertFalse(storage.contains(KEY), "stale value survived schedule " + schedule); + assertNull(storage.getFromCache(KEY, reader)); + assertEquals(expectedRefused, stats.getRefused()); + // a refused put is only refused: it never reaches the store, so it is not a stored put either + assertEquals(expectedStoredPuts, stats.getStoredPuts()); + } + + @Test + void freshPutAfterWriteIsAccepted() { + String regionName = "t3-fresh-put"; + GuardedEntityNonStrictReadWriteAccess access = entityAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + + access.update(writer, KEY, WRITTEN, null, null); + access.unlockItem(writer, KEY, null); + // a reader transaction started after the write reads the new row, so its put is not stale + SharedSessionContractImplementor reader = sessionAt(cacheClock.incrementAndGet()); + + assertTrue(access.putFromLoad(reader, KEY, FRESH, null)); + assertEquals(FRESH, storage.getFromCache(KEY, reader)); + assertEquals(0, stats.getRefused()); + assertEquals(0, stats.getSelfEvicted()); + assertEquals(1, stats.getStoredPuts()); + } + + @Test + void writeLandingMidPutIsSelfEvicted() { + String regionName = "t3-mid-put-race"; + GuardedEntityNonStrictReadWriteAccess access = entityAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + // the writer records and evicts after the reader's guard check but before its value is stored, + // which is the one window the up front check cannot cover + storage.beforeStore = () -> access.update(writer, KEY, WRITTEN, null, null); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null)); + + assertFalse(storage.contains(KEY), "stale value stranded by a write landing mid put"); + assertEquals(1, stats.getSelfEvicted()); + // the up front check passed, so this must not also be counted as a refusal + assertEquals(0, stats.getRefused()); + // nor as a stored put: the value did not stay, so the three outcomes stay mutually exclusive + assertEquals(0, stats.getStoredPuts()); + } + + @Test + void refusalAndSelfEvictionAreCountedPerRegion() { + String regionName = "t3-counters"; + GuardedEntityNonStrictReadWriteAccess access = entityAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + // first key: the write is already recorded when the reader puts, so the put is refused up front + access.update(writer, KEY, WRITTEN, null, null); + assertFalse(access.putFromLoad(reader, KEY, STALE, null)); + + // second key: the write lands between the reader's guard check and its storage put + storage.beforeStore = () -> access.update(writer, OTHER_KEY, WRITTEN, null, null); + assertFalse(access.putFromLoad(reader, OTHER_KEY, STALE, null)); + + assertEquals(1, stats.getRefused()); + assertEquals(1, stats.getSelfEvicted()); + assertFalse(storage.contains(KEY)); + assertFalse(storage.contains(OTHER_KEY)); + } + + static Stream writerPaths() { + return Stream.of( + arguments( + "update", + (WriterPath) (access, session) -> access.update(session, KEY, WRITTEN, null, null)), + // afterUpdate delegates to unlockItem in the superclass, so unlockItem is the guarded one + // of the two. This entry pins that delegation. + arguments( + "afterUpdate", + (WriterPath) + (access, session) -> access.afterUpdate(session, KEY, WRITTEN, null, null, null)), + arguments( + "unlockItem", (WriterPath) (access, session) -> access.unlockItem(session, KEY, null)), + arguments("remove", (WriterPath) (access, session) -> access.remove(session, KEY)), + arguments("removeAll", (WriterPath) GuardedEntityNonStrictReadWriteAccess::removeAll), + arguments("evict", (WriterPath) (access, session) -> access.evict(KEY)), + arguments("evictAll", (WriterPath) (access, session) -> access.evictAll()), + // unlockRegion reaches storage through clearCache, not through evictAll, so it needs an + // override of its own. Hibernate calls it post commit for bulk HQL update and delete + // statements. + arguments("unlockRegion", (WriterPath) (access, session) -> access.unlockRegion(null)), + arguments("destroy", (WriterPath) (access, session) -> access.destroy())); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("writerPaths") + void everyEvictionPathBlocksOlderPuts(String name, WriterPath writerPath) { + String regionName = "t3-writer-" + name; + GuardedEntityNonStrictReadWriteAccess access = entityAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + writerPath.run(access, writer); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null), name + " left the door open"); + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getRefused()); + } + + @Test + void fiveArgumentPutFromLoadIsGuardedThroughDelegation() { + // pins an upstream assumption: the five argument overload of the superclass routes to the four + // argument one virtually, so guarding the four argument one guards both. An upgrade that stops + // delegating fails here instead of silently reopening the window. + String regionName = "t3-five-arg-put"; + GuardedEntityNonStrictReadWriteAccess access = entityAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + access.update(writer, KEY, WRITTEN, null, null); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null, true)); + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getRefused()); + } + + @Test + void collectionAccessRefusesStalePut() { + String regionName = "t3-collection"; + GuardedCollectionNonStrictReadWriteAccess access = collectionAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + // a collection has no update path: Hibernate evicts it on transaction completion + access.unlockItem(writer, KEY, null); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null)); + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getRefused()); + } + + @Test + void collectionAccessSelfEvictsWriteLandingMidPut() { + String regionName = "t3-collection-mid-put"; + GuardedCollectionNonStrictReadWriteAccess access = collectionAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + storage.beforeStore = () -> access.unlockItem(writer, KEY, null); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null)); + + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getSelfEvicted()); + } + + /** One collection writer entry point, the collection twin of {@link WriterPath}. */ + private interface CollectionWriterPath { + void run( + GuardedCollectionNonStrictReadWriteAccess access, SharedSessionContractImplementor session); + } + + static Stream collectionWriterPaths() { + return Stream.of( + arguments( + "unlockItem", + (CollectionWriterPath) (access, session) -> access.unlockItem(session, KEY, null)), + arguments( + "remove", (CollectionWriterPath) (access, session) -> access.remove(session, KEY)), + arguments( + "removeAll", + (CollectionWriterPath) GuardedCollectionNonStrictReadWriteAccess::removeAll), + arguments("evict", (CollectionWriterPath) (access, session) -> access.evict(KEY)), + arguments("evictAll", (CollectionWriterPath) (access, session) -> access.evictAll()), + arguments( + "unlockRegion", (CollectionWriterPath) (access, session) -> access.unlockRegion(null)), + arguments("destroy", (CollectionWriterPath) (access, session) -> access.destroy())); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("collectionWriterPaths") + void everyCollectionEvictionPathBlocksOlderPuts(String name, CollectionWriterPath writerPath) { + String regionName = "t3-collection-writer-" + name; + GuardedCollectionNonStrictReadWriteAccess access = collectionAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + writerPath.run(access, writer); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null), name + " left the door open"); + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getRefused()); + } + + @Test + void collectionFiveArgumentPutFromLoadIsGuardedThroughDelegation() { + String regionName = "t3-collection-five-arg-put"; + GuardedCollectionNonStrictReadWriteAccess access = collectionAccess(regionName); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + access.unlockItem(writer, KEY, null); + + assertFalse(access.putFromLoad(reader, KEY, STALE, null, true)); + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getRefused()); + } + + @Test + void aMissingGuardFailsAtConstruction() { + // a Task 4 wiring mistake must surface when the region builds the access, not on the first + // write + DomainDataRegion region = region("t3-null-guard"); + + assertThrows( + NullPointerException.class, + () -> + new GuardedEntityNonStrictReadWriteAccess( + region, + mock(CacheKeysFactory.class), + storage, + mock(EntityDataCachingConfig.class), + null)); + assertThrows( + NullPointerException.class, + () -> + new GuardedCollectionNonStrictReadWriteAccess( + region, + mock(CacheKeysFactory.class), + storage, + mock(CollectionDataCachingConfig.class), + null)); + } + + @Test + void regionClearThroughOneAccessBarsPutThroughSibling() { + // two entity types sharing one region: one region, one storage, one guard, two access objects + String regionName = "t3-shared-region"; + DomainDataRegion sharedRegion = region(regionName); + GuardedEntityNonStrictReadWriteAccess clearedThrough = entityAccess(sharedRegion); + GuardedEntityNonStrictReadWriteAccess sibling = entityAccess(sharedRegion); + EvictionGuardStats stats = EvictionGuardStats.forRegion(regionName); + SharedSessionContractImplementor writer = mock(SharedSessionContractImplementor.class); + SharedSessionContractImplementor reader = sessionAt(cacheClock.get()); + + // the clear wipes the shared storage, the sibling's keys included + clearedThrough.removeAll(writer); + + assertFalse(sibling.putFromLoad(reader, KEY, STALE, null)); + assertFalse(storage.contains(KEY)); + assertEquals(1, stats.getRefused()); + } + + private GuardedEntityNonStrictReadWriteAccess entityAccess(String regionName) { + return entityAccess(region(regionName)); + } + + private GuardedEntityNonStrictReadWriteAccess entityAccess(DomainDataRegion region) { + return new GuardedEntityNonStrictReadWriteAccess( + region, mock(CacheKeysFactory.class), storage, mock(EntityDataCachingConfig.class), guard); + } + + private GuardedCollectionNonStrictReadWriteAccess collectionAccess(String regionName) { + return new GuardedCollectionNonStrictReadWriteAccess( + region(regionName), + mock(CacheKeysFactory.class), + storage, + mock(CollectionDataCachingConfig.class), + guard); + } + + private DomainDataRegion region(String regionName) { + RegionFactory regionFactory = mock(RegionFactory.class); + when(regionFactory.nextTimestamp()).thenAnswer(invocation -> cacheClock.incrementAndGet()); + DomainDataRegion region = mock(DomainDataRegion.class); + when(region.getName()).thenReturn(regionName); + when(region.getRegionFactory()).thenReturn(regionFactory); + return region; + } + + /** A session whose transaction started at the given Hibernate cache timestamp. */ + private static SharedSessionContractImplementor sessionAt(long cachingTimestamp) { + CacheTransactionSynchronization synchronization = mock(CacheTransactionSynchronization.class); + when(synchronization.getCachingTimestamp()).thenReturn(cachingTimestamp); + SharedSessionContractImplementor session = mock(SharedSessionContractImplementor.class); + when(session.getCacheTransactionSynchronization()).thenReturn(synchronization); + return session; + } + + /** Cache provider replaced by a {@link HashMap}, single threaded on purpose. */ + private static final class MapStorage implements DomainDataStorageAccess { + private final Map data = new HashMap<>(); + + /** Runs before a loaded value is stored, which is how a mid put write is scripted. */ + Runnable beforeStore = () -> {}; + + @Override + public void putFromLoad(Object key, Object value, SharedSessionContractImplementor session) { + beforeStore.run(); + putIntoCache(key, value, session); + } + + @Override + public Object getFromCache(Object key, SharedSessionContractImplementor session) { + return data.get(key); + } + + @Override + public void putIntoCache(Object key, Object value, SharedSessionContractImplementor session) { + data.put(key, value); + } + + @Override + public boolean contains(Object key) { + return data.containsKey(key); + } + + @Override + public void evictData() { + data.clear(); + } + + @Override + public void evictData(Object key) { + data.remove(key); + } + + @Override + public void release() { + data.clear(); + } + } +} diff --git a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/config/HibernateConfigTest.java b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/config/HibernateConfigTest.java index 340e83aecfb9..b61c53b11406 100644 --- a/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/config/HibernateConfigTest.java +++ b/dhis-2/dhis-support/dhis-support-hibernate/src/test/java/org/hisp/dhis/config/HibernateConfigTest.java @@ -39,8 +39,8 @@ import java.util.Properties; import org.hibernate.cache.jcache.ConfigSettings; -import org.hibernate.cache.jcache.internal.JCacheRegionFactory; import org.hibernate.cfg.AvailableSettings; +import org.hisp.dhis.cache.guard.GuardedJCacheRegionFactory; import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -86,7 +86,7 @@ void secondLevelCacheEnabledForAllAllowedSpellings(String configValue) { assertEquals("true", properties.get(AvailableSettings.USE_SECOND_LEVEL_CACHE)); assertEquals("true", properties.get(AvailableSettings.USE_QUERY_CACHE)); assertEquals( - JCacheRegionFactory.class.getName(), + GuardedJCacheRegionFactory.class.getName(), properties.get(AvailableSettings.CACHE_REGION_FACTORY)); assertFalse(properties.containsKey(ConfigSettings.CONFIG_URI)); } diff --git a/dhis-2/dhis-support/dhis-support-system/pom.xml b/dhis-2/dhis-support/dhis-support-system/pom.xml index fe6fa25e32e1..d62aff81770b 100644 --- a/dhis-2/dhis-support/dhis-support-system/pom.xml +++ b/dhis-2/dhis-support/dhis-support-system/pom.xml @@ -281,6 +281,17 @@ + + org.hibernate + hibernate-jcache + test + + + org.hibernate + hibernate-core + + + org.hisp.dhis dhis-support-test diff --git a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfig.java b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfig.java index 5eb5fafc7e40..a4adc3be1869 100644 --- a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfig.java +++ b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfig.java @@ -107,10 +107,15 @@ public void bindEhCacheToRegistry( * Attempts to extract the EHCache CacheManager from the Hibernate RegionFactory using reflection. * Returns the JSR-107 CacheManager wrapper. */ - private javax.cache.CacheManager getEhCacheManager(RegionFactory regionFactory) { + javax.cache.CacheManager getEhCacheManager(RegionFactory regionFactory) { try { - // Common field name for JCacheRegionFactory implementations - java.lang.reflect.Field field = regionFactory.getClass().getDeclaredField("cacheManager"); + java.lang.reflect.Field field = findCacheManagerField(regionFactory.getClass()); + if (field == null) { + log.warn( + "Reflection failed: Field 'cacheManager' not found in {} or any of its superclasses. Cannot monitor EHCache.", + regionFactory.getClass().getName()); + return null; + } field.setAccessible(true); Object cacheManagerObj = field.get(regionFactory); @@ -127,11 +132,6 @@ private javax.cache.CacheManager getEhCacheManager(RegionFactory regionFactory) cacheManagerObj != null ? cacheManagerObj.getClass().getName() : "null"); return null; } - } catch (NoSuchFieldException nsfe) { - log.warn( - "Reflection failed: Field 'cacheManager' not found in {}. Cannot monitor EHCache.", - regionFactory.getClass().getName()); - return null; } catch (Exception e) { log.warn( "Could not access CacheManager via reflection on {}: {}", @@ -142,6 +142,26 @@ private javax.cache.CacheManager getEhCacheManager(RegionFactory regionFactory) } } + /** + * Finds the 'cacheManager' field declared by the given type or by any of its superclasses. {@code + * getDeclaredField} only looks at the class it is called on, so the configured region factory + * being a subclass of JCacheRegionFactory (which is where the field is declared) would otherwise + * silently disable every EHCache metric. + * + * @param type the concrete region factory class to start the walk at. + * @return the field, or null if neither the type nor any superclass declares it. + */ + static java.lang.reflect.Field findCacheManagerField(Class type) { + for (Class current = type; current != null; current = current.getSuperclass()) { + try { + return current.getDeclaredField("cacheManager"); + } catch (NoSuchFieldException nsfe) { + // Not declared here, keep walking up until Object, whose superclass is null. + } + } + return null; + } + /** * Registers metrics for each cache using reflection to access statistics directly from the * JSR-107 cache wrapper. diff --git a/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EvictionGuardMetricsConfig.java b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EvictionGuardMetricsConfig.java new file mode 100644 index 000000000000..d23d4560d284 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-system/src/main/java/org/hisp/dhis/monitoring/metrics/EvictionGuardMetricsConfig.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.monitoring.metrics; + +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.cache.guard.EvictionGuardStats; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; + +/** + * Exposes the second-level cache eviction guard counters, three per region the guard has seen. + * + *

Ungated on purpose, unlike the sibling {@code *MetricsConfig} classes: those cost something + * (Hibernate statistics, reflective cache walks, JVM polling) and default to off. These counters + * are three {@code LongAdder}s per region that the guard increments anyway, so exposing them costs + * one {@link FunctionCounter} registration each at boot and nothing after that. They are also the + * guard's only observability, and a stale-cache incident is diagnosed after the fact, not after a + * config flip and a restart. + * + *

Binding is a no-op when the context holds no {@link MeterRegistry}, the same tolerance as + * {@code StaticCacheMetrics}. The {@code region} tag carries the full Hibernate region name (the + * entity or collection FQN, also the JCache cache name behind the {@code ehcache_*} series), kept + * unshortened so the two series stay joinable and unambiguous. + * + *

{@code @DependsOn("entityManagerFactory")} is ordering only: the guard registers a region's + * counters while Hibernate builds that region, so this binder must run after the + * EntityManagerFactory exists or it would find the registry empty. Regions are fixed at boot; none + * turn up later. + * + * @author Morten Svanæs + */ +@Slf4j +@Configuration +@DependsOn("entityManagerFactory") +public class EvictionGuardMetricsConfig { + + @Autowired + public void bindEvictionGuardToRegistry(ObjectProvider registryProvider) { + MeterRegistry registry = registryProvider.getIfAvailable(); + if (registry == null) { + log.debug("No MeterRegistry present, eviction guard counters not exposed."); + return; + } + registerGuardMetrics(registry); + } + + /** + * Registers the counters of every region the guard has seen. The counters are function counters + * over the live per-region adders, so they keep tracking after registration. + */ + void registerGuardMetrics(MeterRegistry registry) { + Map guardStats = EvictionGuardStats.all(); + if (guardStats.isEmpty()) { + log.debug("No eviction guard regions registered, guard counters not exposed."); + return; + } + log.info("Registering eviction guard counters for {} regions.", guardStats.size()); + + for (EvictionGuardStats stats : guardStats.values()) { + Tags guardTags = Tags.of(Tag.of("region", stats.getRegionName())); + + FunctionCounter.builder( + "hibernate_l2_guard_refused_puts_total", stats, EvictionGuardStats::getRefused) + .tags(guardTags) + .description( + "The total number of second-level cache puts the eviction guard refused because the transaction's caching timestamp predates the key's last recorded eviction or the region's last recorded clear. A nonzero count is expected in normal operation, not an incident: DHIS2 clears whole regions on metadata mutations, and sessions older than a clear then have their puts refused. Read it as the volume of refused late puts; hibernate_l2_guard_self_evictions_total is the signal that the mid-put race fired") + .register(registry); + + FunctionCounter.builder( + "hibernate_l2_guard_self_evictions_total", stats, EvictionGuardStats::getSelfEvicted) + .tags(guardTags) + .description( + "The total number of values a reader stored and then took back, because a write landed between the guard check and the store and the post-store re-check saw the newer eviction. A reader undoing its own put, not writer bookkeeping") + .register(registry); + + FunctionCounter.builder( + "hibernate_l2_guard_stored_puts_total", stats, EvictionGuardStats::getStoredPuts) + .tags(guardTags) + .description( + "The total number of second-level cache puts the guard let through and that stayed in the region's storage: the up-front check passed, the value was stored, and the post-store re-check still allowed it. A nonzero and growing count is normal operation, not an incident, it is cache misses reloading rows into the region. It is the denominator the other two guard counters are read against, and per region it should account for the region's own put count together with hibernate_l2_guard_self_evictions_total, since a NONSTRICT_READ_WRITE region is only ever populated through putFromLoad: an ehcache_puts series above that sum means something writes to the region without passing the guard") + .register(registry); + } + } +} diff --git a/dhis-2/dhis-support/dhis-support-system/src/test/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfigTest.java b/dhis-2/dhis-support/dhis-support-system/src/test/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfigTest.java new file mode 100644 index 000000000000..6e5a39f26956 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-system/src/test/java/org/hisp/dhis/monitoring/metrics/EhCacheMetricsConfigTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.monitoring.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; + +import java.lang.reflect.Field; +import org.hibernate.cache.jcache.internal.JCacheRegionFactory; +import org.hibernate.cache.spi.RegionFactory; +import org.hisp.dhis.cache.guard.GuardedJCacheRegionFactory; +import org.junit.jupiter.api.Test; + +/** + * Tests the reflective {@code cacheManager} lookup of {@link EhCacheMetricsConfig}: it has to find + * the field when the configured region factory is a subclass of {@link JCacheRegionFactory}, which + * is exactly what {@link GuardedJCacheRegionFactory} makes it. The eviction guard counters are + * bound by {@link EvictionGuardMetricsConfig} and tested there. + * + * @author Morten Svanæs + */ +class EhCacheMetricsConfigTest { + + /** + * Stands in for {@code GuardedJCacheRegionFactory}: any subclass at all is enough to break a + * lookup that only looks at the concrete class. + */ + private static class SubclassedRegionFactory extends JCacheRegionFactory {} + + @Test + void findsCacheManagerFieldDeclaredBySuperclass() { + Field field = EhCacheMetricsConfig.findCacheManagerField(SubclassedRegionFactory.class); + + assertNotNull(field, "cacheManager field inherited from JCacheRegionFactory must be found"); + assertEquals(JCacheRegionFactory.class, field.getDeclaringClass()); + } + + @Test + void findsCacheManagerFieldOnTheConfiguredGuardedRegionFactory() { + Field field = EhCacheMetricsConfig.findCacheManagerField(GuardedJCacheRegionFactory.class); + + assertNotNull(field, "the region factory DHIS actually configures must not lose its metrics"); + assertEquals(JCacheRegionFactory.class, field.getDeclaringClass()); + } + + @Test + void readsCacheManagerFromSubclassedRegionFactory() throws Exception { + SubclassedRegionFactory regionFactory = new SubclassedRegionFactory(); + javax.cache.CacheManager cacheManager = mock(javax.cache.CacheManager.class); + Field field = JCacheRegionFactory.class.getDeclaredField("cacheManager"); + field.setAccessible(true); + field.set(regionFactory, cacheManager); + + assertSame(cacheManager, new EhCacheMetricsConfig().getEhCacheManager(regionFactory)); + } + + @Test + void returnsNullWhenNoCacheManagerFieldExistsAnywhere() { + RegionFactory regionFactory = mock(RegionFactory.class); + + assertNull(EhCacheMetricsConfig.findCacheManagerField(regionFactory.getClass())); + assertNull(new EhCacheMetricsConfig().getEhCacheManager(regionFactory)); + } +} diff --git a/dhis-2/dhis-support/dhis-support-system/src/test/java/org/hisp/dhis/monitoring/metrics/EvictionGuardMetricsConfigTest.java b/dhis-2/dhis-support/dhis-support-system/src/test/java/org/hisp/dhis/monitoring/metrics/EvictionGuardMetricsConfigTest.java new file mode 100644 index 000000000000..84e8902e9f44 --- /dev/null +++ b/dhis-2/dhis-support/dhis-support-system/src/test/java/org/hisp/dhis/monitoring/metrics/EvictionGuardMetricsConfigTest.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.monitoring.metrics; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.persistence.EntityManagerFactory; +import org.hisp.dhis.cache.guard.EvictionGuardStats; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + +/** + * Tests that {@link EvictionGuardMetricsConfig} exposes the eviction guard counters per region, + * that the counters keep tracking after they are bound, that the bean binds through Spring with no + * monitoring flag set anywhere, and that binding tolerates a context without a {@link + * MeterRegistry}. The last two are the point of the class: unlike its siblings in this package it + * carries no condition, so the counters are visible on a default install. + * + *

Region names are prefixed {@code t6-} because {@link EvictionGuardStats} keeps a process wide + * registry with no reset, so every test class in the build has to pick names nobody else uses. + * + * @author Morten Svanæs + */ +class EvictionGuardMetricsConfigTest { + + @Test + void registersGuardCountersPerRegion() { + EvictionGuardStats.forRegion("t6-refused").countRefused(); + EvictionGuardStats.forRegion("t6-evicted").countSelfEvicted(); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + new EvictionGuardMetricsConfig().registerGuardMetrics(registry); + + assertEquals( + 1.0, + registry + .get("hibernate_l2_guard_refused_puts_total") + .tag("region", "t6-refused") + .functionCounter() + .count()); + assertEquals( + 0.0, + registry + .get("hibernate_l2_guard_self_evictions_total") + .tag("region", "t6-refused") + .functionCounter() + .count()); + assertEquals( + 1.0, + registry + .get("hibernate_l2_guard_self_evictions_total") + .tag("region", "t6-evicted") + .functionCounter() + .count()); + } + + @Test + void registersStoredPutsCounterPerRegion() { + EvictionGuardStats stats = EvictionGuardStats.forRegion("t6-stored-puts"); + stats.countStoredPut(); + stats.countStoredPut(); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + new EvictionGuardMetricsConfig().registerGuardMetrics(registry); + + assertEquals( + 2.0, + registry + .get("hibernate_l2_guard_stored_puts_total") + .tag("region", "t6-stored-puts") + .functionCounter() + .count()); + assertEquals( + 0.0, + registry + .get("hibernate_l2_guard_refused_puts_total") + .tag("region", "t6-stored-puts") + .functionCounter() + .count()); + } + + @Test + void guardCountersTrackLaterIncrements() { + EvictionGuardStats stats = EvictionGuardStats.forRegion("t6-live"); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + new EvictionGuardMetricsConfig().registerGuardMetrics(registry); + stats.countRefused(); + stats.countRefused(); + + assertEquals( + 2.0, + registry + .get("hibernate_l2_guard_refused_puts_total") + .tag("region", "t6-live") + .functionCounter() + .count()); + } + + @Test + @SuppressWarnings("unchecked") + void bindingWithoutMeterRegistryIsANoOp() { + ObjectProvider registryProvider = mock(ObjectProvider.class); + + assertDoesNotThrow( + () -> new EvictionGuardMetricsConfig().bindEvictionGuardToRegistry(registryProvider)); + } + + @Test + void bindsThroughSpringWithNoMonitoringFlagSet() { + // the config class carries @DependsOn("entityManagerFactory") for boot ordering, so the context + // needs a bean under exactly that name; a mock is enough because the binder never touches it + EvictionGuardStats.forRegion("t6-spring").countRefused(); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean( + "entityManagerFactory", + EntityManagerFactory.class, + () -> mock(EntityManagerFactory.class)); + context.registerBean(MeterRegistry.class, () -> registry); + context.register(EvictionGuardMetricsConfig.class); + context.refresh(); + + assertEquals( + 1.0, + registry + .get("hibernate_l2_guard_refused_puts_total") + .tag("region", "t6-spring") + .functionCounter() + .count()); + } + } +} diff --git a/dhis-2/dhis-test-integration/pom.xml b/dhis-2/dhis-test-integration/pom.xml index 88981f75ab5c..803391cdfb30 100644 --- a/dhis-2/dhis-test-integration/pom.xml +++ b/dhis-2/dhis-test-integration/pom.xml @@ -289,6 +289,23 @@ hibernate-core-jakarta test + + org.hibernate + hibernate-jcache + test + + + org.hibernate + hibernate-core + + + + + org.ehcache + ehcache + jakarta + test + jakarta.persistence jakarta.persistence-api @@ -394,7 +411,13 @@ org.hisp.dhis:dhis-support-jdbc org.hisp.dhis:dhis-support-system org.hisp.dhis:json-tree + + javax.cache:cache-api + + javax.cache:cache-api:jar + org.hisp.dhis:dhis-service-setting diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/HibernateCacheRegionsTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/HibernateCacheRegionsTest.java new file mode 100644 index 000000000000..2fb96be9ae97 --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/HibernateCacheRegionsTest.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.persistence.EntityManagerFactory; +import java.util.Set; +import javax.cache.CacheManager; +import javax.cache.Caching; +import org.hibernate.Session; +import org.hibernate.cache.jcache.internal.JCacheRegionFactory; +import org.hibernate.cache.spi.RegionFactory; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.stat.CacheRegionStatistics; +import org.hibernate.stat.Statistics; +import org.hisp.dhis.organisationunit.OrganisationUnit; +import org.hisp.dhis.organisationunit.OrganisationUnitService; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.hisp.dhis.user.User; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Effect tests for the second level cache configuration that Postgres integration tests (and CI) + * actually run with ({@code postgresTestDhis.conf}: cache on, query cache off, no ehcache.xml): the + * region factory Hibernate resolved, the cache regions that exist at boot, and the fact that the + * JCache {@link CacheManager} runs on the ehcache provider default configuration, not on + * ehcache.xml. Also proves the per-flow region behavior on real production mappings: a second + * entity load by id is a region hit, and a collection region hit rehydrates every element + * individually through the entity region (F5, N+1 on hit). + * + * @author Morten Svanæs + */ +class HibernateCacheRegionsTest extends PostgresIntegrationTestBase { + + @Autowired private EntityManagerFactory entityManagerFactory; + + /** Number of children of the parent organisation unit in the collection flow test. */ + private static final int CHILD_COUNT = 3; + + @Autowired private OrganisationUnitService organisationUnitService; + + @Test + void secondLevelCacheIsOnWithJCacheRegionFactory() { + assertInstanceOf(JCacheRegionFactory.class, regionFactory()); + } + + @Test + void allCachedEntityAndCollectionRegionsExistAtBoot() { + Set regions = sessionFactory().getCache().getCacheRegionNames(); + + assertNotNull(regions); + assertTrue(regions.contains(User.class.getName()), "User entity region must exist: " + regions); + assertTrue( + regions.contains(OrganisationUnit.class.getName()), + "OrganisationUnit entity region must exist: " + regions); + // The cached-region inventory is ~235 declarations: 33 annotated entities plus ~202 + // cached collections in hbm.xml mappings. Guard the order of magnitude so a mapping + // change that silently drops caching for whole groups of regions is caught. + assertTrue( + regions.size() >= 200, + "expected the full cached-region inventory (~235), but found: " + regions.size()); + } + + @Test + void cacheManagerRunsOnProviderDefaultsWithoutEhcacheConfigFile() { + JCacheRegionFactory regionFactory = + assertInstanceOf(JCacheRegionFactory.class, regionFactory()); + CacheManager cacheManager = regionFactory.getCacheManager(); + + assertNotNull(cacheManager); + assertEquals( + Caching.getCachingProvider().getDefaultURI(), + cacheManager.getURI(), + "with a blank cache.ehcache.config.file the CacheManager must run on the ehcache" + + " provider default configuration"); + } + + @Test + void secondEntityLoadByIdIsARegionHit() { + OrganisationUnit unit = createOrganisationUnit('A'); + organisationUnitService.addOrganisationUnit(unit); + + Statistics statistics = enableStatistics(); + sessionFactory().getCache().evictEntityData(OrganisationUnit.class); + statistics.clear(); + + loadOrganisationUnitInNewSession(unit.getId()); + CacheRegionStatistics regionStatistics = entityRegionStatistics(statistics); + assertEquals(1, regionStatistics.getMissCount(), "first load must miss the region"); + assertEquals(1, regionStatistics.getPutCount(), "first load must populate the region"); + assertEquals(0, regionStatistics.getHitCount(), "first load cannot hit the region"); + + loadOrganisationUnitInNewSession(unit.getId()); + regionStatistics = entityRegionStatistics(statistics); + assertEquals(1, regionStatistics.getHitCount(), "second load must hit the region"); + assertEquals(1, regionStatistics.getMissCount(), "second load must not miss the region"); + } + + /** + * Encodes F5 on a real production mapping ({@code OrganisationUnit.children}): collection regions + * store element ids only, so a collection region HIT with a cold element region rehydrates every + * element individually through the entity region and, on miss, the database. The exact SQL cost + * of this flow (one SELECT per element) is pinned by HibernateCacheEffectTest in + * dhis-support-hibernate. + */ + @Test + void collectionCacheHitRehydratesEachElementThroughTheEntityRegion() { + OrganisationUnit parent = createOrganisationUnit('B'); + organisationUnitService.addOrganisationUnit(parent); + for (int i = 0; i < CHILD_COUNT; i++) { + organisationUnitService.addOrganisationUnit(createOrganisationUnit((char) ('C' + i), parent)); + } + + Statistics statistics = enableStatistics(); + sessionFactory().getCache().evictAllRegions(); + // warm the parent and element entity regions and the children collection region + readChildrenInNewSession(parent.getId()); + // cold entity region, warm collection region: the steady state after any entity region + // eviction (bounded heap, TTL, write) while the collection entry survives + sessionFactory().getCache().evictEntityData(OrganisationUnit.class); + statistics.clear(); + + readChildrenInNewSession(parent.getId()); + + CacheRegionStatistics collectionStatistics = + statistics.getDomainDataRegionStatistics(OrganisationUnit.class.getName() + ".children"); + assertNotNull(collectionStatistics); + assertEquals(1, collectionStatistics.getHitCount(), "children collection region must hit"); + assertEquals( + 1 + CHILD_COUNT, + entityRegionStatistics(statistics).getMissCount(), + "the parent and every collection element must be rehydrated individually"); + } + + private SessionFactoryImplementor sessionFactory() { + // Unwrap to the implementor directly: the Spring-managed EntityManagerFactory is a + // proxy, and unwrapping to plain SessionFactory returns a proxy that cannot be cast + return entityManagerFactory.unwrap(SessionFactoryImplementor.class); + } + + private RegionFactory regionFactory() { + return sessionFactory().getServiceRegistry().getService(RegionFactory.class); + } + + private Statistics enableStatistics() { + Statistics statistics = sessionFactory().getStatistics(); + statistics.setStatisticsEnabled(true); + return statistics; + } + + private CacheRegionStatistics entityRegionStatistics(Statistics statistics) { + CacheRegionStatistics regionStatistics = + statistics.getDomainDataRegionStatistics(OrganisationUnit.class.getName()); + assertNotNull(regionStatistics); + return regionStatistics; + } + + private void loadOrganisationUnitInNewSession(long id) { + try (Session session = sessionFactory().openSession()) { + assertNotNull(session.get(OrganisationUnit.class, id)); + } + } + + private void readChildrenInNewSession(long parentId) { + try (Session session = sessionFactory().openSession()) { + OrganisationUnit parent = session.get(OrganisationUnit.class, parentId); + assertNotNull(parent); + int read = 0; + for (OrganisationUnit child : parent.getChildren()) { + assertNotNull(child.getName()); + read++; + } + assertEquals(CHILD_COUNT, read, "all children must be readable"); + } + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/HibernateEhcacheConfigFileTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/HibernateEhcacheConfigFileTest.java new file mode 100644 index 000000000000..27698435174f --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/HibernateEhcacheConfigFileTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.persistence.EntityManagerFactory; +import javax.cache.CacheManager; +import javax.cache.Caching; +import org.ehcache.config.CacheRuntimeConfiguration; +import org.ehcache.config.ResourceType; +import org.ehcache.config.SizedResourcePool; +import org.ehcache.jsr107.Eh107Configuration; +import org.hibernate.SessionFactory; +import org.hibernate.cache.jcache.internal.JCacheRegionFactory; +import org.hibernate.cache.spi.RegionFactory; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hisp.dhis.attribute.Attribute; +import org.hisp.dhis.cache.HibernateEhcacheConfigFileTest.DhisConfig; +import org.hisp.dhis.external.conf.ConfigurationKey; +import org.hisp.dhis.test.config.PostgresTestConfigOverride; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.hisp.dhis.user.User; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.ContextConfiguration; + +/** + * Proves that the {@code cache.ehcache.config.file} default value ({@code classpath:ehcache.xml}) + * loads against the full production {@link SessionFactory}: the JCache {@link CacheManager} is + * configured from ehcache.xml and the regions carry its settings (bounded heaps) instead of the + * unbounded ehcache provider defaults. Until the classpath: spelling was normalized in + * HibernateConfig, this exact configuration failed the SessionFactory boot with "Couldn't load URI + * from classpath:ehcache.xml". + * + * @author Morten Svanæs + */ +@ContextConfiguration(classes = {DhisConfig.class}) +class HibernateEhcacheConfigFileTest extends PostgresIntegrationTestBase { + + static class DhisConfig { + @Bean + public PostgresTestConfigOverride postgresTestConfigOverride() { + PostgresTestConfigOverride override = new PostgresTestConfigOverride(); + override.put( + ConfigurationKey.CACHE_EHCACHE_CONFIG_FILE.getKey(), + ConfigurationKey.CACHE_EHCACHE_CONFIG_FILE.getDefaultValue()); + return override; + } + } + + /** Heap bound for the update timestamps region declared in ehcache.xml. */ + private static final long EHCACHE_XML_TIMESTAMPS_HEAP_ENTRIES = 5_000; + + /** Heap bound from the ehcache.xml default cache template (jsr107:defaults). */ + private static final long EHCACHE_XML_TEMPLATE_HEAP_ENTRIES = 1_000_000; + + /** + * Heap bound declared explicitly for the predefined {@code org.hisp.dhis.user.User} region in + * ehcache.xml. Hot regions are declared individually so they can be sized and stored by + * reference; the rest still inherit the default template above. + */ + private static final long EHCACHE_XML_USER_HEAP_ENTRIES = 100_000; + + @Autowired private EntityManagerFactory entityManagerFactory; + + @Test + void cacheManagerIsConfiguredFromEhcacheXml() { + CacheManager cacheManager = cacheManager(); + + assertNotEquals( + Caching.getCachingProvider().getDefaultURI(), + cacheManager.getURI(), + "CacheManager must not run on the provider default configuration"); + assertTrue( + cacheManager.getURI().toString().contains("ehcache.xml"), + "CacheManager must be configured from ehcache.xml: " + cacheManager.getURI()); + } + + @Test + void regionsCarryTheEhcacheXmlHeapBounds() { + CacheManager cacheManager = cacheManager(); + + // Attribute has no explicit element, so it must inherit the default template. + assertEquals( + EHCACHE_XML_TEMPLATE_HEAP_ENTRIES, + heapEntries(cacheManager, Attribute.class.getName()), + "entity regions without an explicit declaration must carry the heap bound of the" + + " ehcache.xml default template"); + // User is declared explicitly in ehcache.xml and must carry its own bound, not the template's. + assertEquals( + EHCACHE_XML_USER_HEAP_ENTRIES, + heapEntries(cacheManager, User.class.getName()), + "explicitly declared entity regions must carry their own ehcache.xml heap bound"); + assertEquals( + EHCACHE_XML_TIMESTAMPS_HEAP_ENTRIES, + heapEntries(cacheManager, "default-update-timestamps-region"), + "update timestamps region must carry the heap bound declared in ehcache.xml"); + } + + private CacheManager cacheManager() { + // Unwrap to the implementor directly: the Spring-managed EntityManagerFactory is a + // proxy, and unwrapping to plain SessionFactory returns a proxy that cannot be cast + RegionFactory regionFactory = + entityManagerFactory + .unwrap(SessionFactoryImplementor.class) + .getServiceRegistry() + .getService(RegionFactory.class); + JCacheRegionFactory jCacheRegionFactory = + assertInstanceOf(JCacheRegionFactory.class, regionFactory); + CacheManager cacheManager = jCacheRegionFactory.getCacheManager(); + assertNotNull(cacheManager); + return cacheManager; + } + + private static long heapEntries(CacheManager cacheManager, String region) { + javax.cache.Cache cache = cacheManager.getCache(region); + assertNotNull(cache, "cache region must exist: " + region); + Eh107Configuration configuration = cache.getConfiguration(Eh107Configuration.class); + CacheRuntimeConfiguration runtimeConfiguration = + configuration.unwrap(CacheRuntimeConfiguration.class); + SizedResourcePool heap = + runtimeConfiguration.getResourcePools().getPoolForResource(ResourceType.Core.HEAP); + assertNotNull(heap, "cache region must have a heap resource pool: " + region); + return heap.getSize(); + } +} diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dxf2/metadata/DataSetMetadataExportServiceQueryCountTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dxf2/metadata/DataSetMetadataExportServiceQueryCountTest.java index 0b50d03aaf15..99cc990a6d3c 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dxf2/metadata/DataSetMetadataExportServiceQueryCountTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dxf2/metadata/DataSetMetadataExportServiceQueryCountTest.java @@ -115,10 +115,15 @@ void dataElementCountDoesNotScaleQueryCount() { // DataElement / DataSetElement loads are batched into a single query, so the select count must // not grow with the number of data elements. If it does, the N+1 has been reintroduced (e.g. by // iterating DataSet.getDataElements() or DataElement.getCategoryCombos() lazily). - assertEquals( - baseline, - withMoreDataElements, - "adding data elements must not increase the number of SQL selects"); + // The count may legitimately DROP between the two measurements: whatever the first export + // loaded can still be served from the second level cache during the second one, so this + // asserts the invariant the test name states (no growth) rather than exact equality. + assertTrue( + withMoreDataElements <= baseline, + "adding data elements must not increase the number of SQL selects: " + + baseline + + " -> " + + withMoreDataElements); } @Test diff --git a/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheRampSimulation.java b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheRampSimulation.java new file mode 100644 index 000000000000..95f91165cc33 --- /dev/null +++ b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheRampSimulation.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.test.platform; + +import static io.gatling.javaapi.core.CoreDsl.constantConcurrentUsers; +import static io.gatling.javaapi.core.CoreDsl.exec; +import static io.gatling.javaapi.core.CoreDsl.feed; +import static io.gatling.javaapi.core.CoreDsl.jsonPath; +import static io.gatling.javaapi.core.CoreDsl.listFeeder; +import static io.gatling.javaapi.core.CoreDsl.rampConcurrentUsers; +import static io.gatling.javaapi.core.CoreDsl.scenario; +import static io.gatling.javaapi.http.HttpDsl.flushCookieJar; +import static io.gatling.javaapi.http.HttpDsl.http; +import static io.gatling.javaapi.http.HttpDsl.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.FeederBuilder; +import io.gatling.javaapi.core.PopulationBuilder; +import io.gatling.javaapi.core.ScenarioBuilder; +import io.gatling.javaapi.core.Simulation; +import io.gatling.javaapi.http.HttpProtocolBuilder; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.function.Function; + +/** + * Base class for the L2-cache concurrency-baseline ramp simulations. + * + *

Runs the same workflow at a sequence of fixed concurrency plateaus (default 10, 50, 100, 200, + * 400 concurrent users), one closed-model population per step, chained with {@code andThen} so the + * steps never overlap. Every request name is prefixed with the step label ({@code c010}, {@code + * c050}, ...) so per-step p50/p95/p99 fall out of the standard Gatling stats without timestamp + * arithmetic. + * + *

Virtual users authenticate once (session login, separately named request so bcrypt cost does + * not pollute endpoint latencies) and then loop the workflow with no think time for the step + * duration: this is a deliberate worst-case pressure profile for the Hibernate second-level cache + * region locks ({@code AbstractReadWriteAccess}), not a realistic user model. + * + *

No Gatling assertions on purpose: these simulations produce a measurement baseline (cache ON + * vs OFF, baseline vs candidate); a failed run must still yield its artifact bundle. Regression + * gates belong to the before/after comparison consuming the bundles, not to the simulation. + * + *

Available properties (system property first, then optional {@code -DconfigFile=} properties + * file, then default): + * + *

    + *
  • {@code baseUrl} (default: {@code http://localhost:8080}) + *
  • {@code username} (default: {@code admin}) + *
  • {@code password} (default: {@code district}) + *
  • {@code steps} (default: {@code 10,50,100,200,400} concurrent users) + *
  • {@code stepDurationSec} (default: {@code 60}) plateau duration per step + *
  • {@code rampDurationSec} (default: {@code 10}) ramp-up into each plateau + *
  • {@code orgUnitUid} (default: {@code ImspTQPwCqd} — Sierra Leone root org unit) + *
  • {@code writePercent} (default: {@code 5}) share of workflow iterations that issue a + * metadata write (only used by the write-mixed simulation) + *
+ * + * @author Morten Svanæs + */ +abstract class L2CacheRampSimulation extends Simulation { + + private static final Properties CONFIG = loadConfig(); + + private static Properties loadConfig() { + String path = System.getProperty("configFile"); + Properties props = new Properties(); + if (path != null) { + try (FileInputStream fis = new FileInputStream(path)) { + props.load(fis); + System.out.println("[L2CacheRampSimulation] Loaded config from: " + path); + } catch (IOException e) { + System.err.println( + "[L2CacheRampSimulation] Warning: could not load configFile=" + + path + + ": " + + e.getMessage()); + } + } + return props; + } + + protected static String prop(String key, String defaultValue) { + String sys = System.getProperty(key); + if (sys != null) return sys; + String file = CONFIG.getProperty(key); + return file != null ? file : defaultValue; + } + + protected static final String BASE_URL = prop("baseUrl", "http://localhost:8080"); + protected static final String USERNAME = prop("username", "admin"); + protected static final String PASSWORD = prop("password", "district"); + protected static final String BASIC_AUTH = + Base64.getEncoder() + .encodeToString((USERNAME + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8)); + protected static final String ORG_UNIT_UID = prop("orgUnitUid", "ImspTQPwCqd"); + protected static final int STEP_DURATION_SEC = Integer.parseInt(prop("stepDurationSec", "60")); + protected static final int RAMP_DURATION_SEC = Integer.parseInt(prop("rampDurationSec", "10")); + protected static final int WRITE_PERCENT = Integer.parseInt(prop("writePercent", "5")); + + protected static final List STEPS = + Arrays.stream(prop("steps", "10,50,100,200,400").split(",")) + .map(String::trim) + .map(Integer::parseInt) + .toList(); + + /** + * Data element UIDs fetched once at simulation start; fed into by-id reads and metadata writes so + * load spreads over real entities instead of one hardcoded UID. + */ + protected static final FeederBuilder DATA_ELEMENT_FEEDER = + listFeeder(fetchDataElementUids()).random(); + + private static List> fetchDataElementUids() { + String url = BASE_URL + "/api/dataElements.json?fields=id&paging=false"; + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Basic " + BASIC_AUTH) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException( + "Fetching data element UIDs failed with HTTP " + response.statusCode()); + } + JsonNode root = new ObjectMapper().readTree(response.body()); + List> records = new ArrayList<>(); + for (JsonNode de : root.path("dataElements")) { + records.add(Map.of("deUid", de.path("id").asText())); + } + if (records.isEmpty()) { + throw new IllegalStateException("No data elements found at " + url); + } + System.out.println( + "[L2CacheRampSimulation] Fetched " + records.size() + " data element UIDs"); + return records; + } catch (IOException e) { + throw new IllegalStateException("Failed to fetch data element UIDs from " + url, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while fetching data element UIDs", e); + } + } + + /** + * Hot-metadata read chain shared by both simulations; {@code p} is the step name prefix. + * + *

Checks assert the JSON body, not just HTTP 200: a collapsed server can fail logins and + * bounce sessionless users to the login page, which answers 200 text/html in milliseconds (see + * the comment in {@code L2CacheTrackerImportRampTest#workflow}). + */ + protected static ChainBuilder reads(String p) { + return exec(http(p + " me") + .get("/api/me") + .check(status().is(200), jsonPath("$.id").find().exists())) + .exec( + http(p + " dataElement byId") + .get("/api/dataElements/#{deUid}") + .check(status().is(200), jsonPath("$.id").find().exists())) + .exec( + http(p + " dataElements list") + .get("/api/dataElements") + .queryParam("pageSize", "50") + .queryParam("page", "#{randomInt(1,5)}") + .check(status().is(200), jsonPath("$").find().exists())) + .exec( + http(p + " dataElements filtered") + .get("/api/dataElements") + .queryParam( + "fields", "id,name,categoryCombo[id,name,categoryOptionCombos[id,name]]") + .queryParam("pageSize", "50") + .queryParam("page", "#{randomInt(1,5)}") + .check(status().is(200), jsonPath("$").find().exists())) + .exec( + http(p + " categoryCombos filtered") + .get("/api/categoryCombos") + .queryParam("fields", "id,name,categories[id,name,categoryOptions[id,name]]") + .check(status().is(200), jsonPath("$").find().exists())) + .exec( + http(p + " orgUnits filtered") + .get("/api/organisationUnits") + .queryParam("fields", "id,name,level,parent[id,name]") + .queryParam("pageSize", "100") + .queryParam("page", "#{randomInt(1,10)}") + .check(status().is(200), jsonPath("$").find().exists())) + .exec( + http(p + " orgUnit subtree") + .get("/api/organisationUnits/" + ORG_UNIT_UID) + .queryParam("fields", "id,name,children[id,name,children[id,name]]") + .check(status().is(200), jsonPath("$").find().exists())); + } + + /** + * Builds one closed-model population per concurrency step and installs them sequentially. Called + * exactly once from the concrete simulation's constructor. + * + * @param workflowFactory step-name-prefix -> workflow chain executed in a loop by every virtual + * user for the step duration + */ + protected void install(Function workflowFactory) { + // No protocol-level basicAuth: DHIS2 is stateful, so authenticate once per virtual user via a + // separately-named request and let the session cookie carry the rest -- same pattern as + // UsersPerformanceTest. + HttpProtocolBuilder httpProtocol = + http.baseUrl(BASE_URL).acceptHeader("application/json").disableCaching(); + + PopulationBuilder all = null; + for (int users : STEPS) { + String p = String.format("c%03d", users); + ChainBuilder authenticate = + exec(flushCookieJar()) + .exec( + http(p + " login") + .get("/api/me") + .header("Authorization", "Basic " + BASIC_AUTH) + .check(status().is(200), jsonPath("$.id").find().exists())); + + ScenarioBuilder scn = + scenario(getClass().getSimpleName() + " " + p) + .exec(authenticate) + .during(Duration.ofSeconds(STEP_DURATION_SEC)) + .on(feed(DATA_ELEMENT_FEEDER).exec(workflowFactory.apply(p))); + + PopulationBuilder pop = + scn.injectClosed( + rampConcurrentUsers(0).to(users).during(Duration.ofSeconds(RAMP_DURATION_SEC)), + constantConcurrentUsers(users).during(Duration.ofSeconds(STEP_DURATION_SEC))); + + all = all == null ? pop : all.andThen(pop); + } + + // ramp + plateau per step, plus a full plateau of tail allowance (users started late in the + // injection window run their whole during() loop after injection stops) + int totalSeconds = STEPS.size() * (RAMP_DURATION_SEC + 2 * STEP_DURATION_SEC); + setUp(all).protocols(httpProtocol).maxDuration(Duration.ofSeconds(totalSeconds + 300)); + } +} diff --git a/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheReadHeavyRampTest.java b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheReadHeavyRampTest.java new file mode 100644 index 000000000000..7dc4eeccc4f2 --- /dev/null +++ b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheReadHeavyRampTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.test.platform; + +/** + * Read-heavy concurrency ramp for the L2 cache baseline. + * + *

Hot-metadata reads only: {@code /api/me} (exercises User's 8 cached collections, among the + * hottest regions measured), data elements (list, field-filtered and by-id), category combos with + * the Category* regions, and organisation units (paged list + subtree with cached children + * collections). Under cache ON every request funnels through {@code AbstractReadWriteAccess} region + * read locks; putFromLoad (miss path) and query-cache puts take the region write lock. + * + *

{@code
+ * DHIS2_IMAGE=dhis2/core-l2truth:local \
+ * DHIS_CONF_FILE=dhis-l2cache-on.conf \
+ * SIMULATION_CLASS=org.hisp.dhis.test.platform.L2CacheReadHeavyRampTest \
+ * ./run-simulation.sh
+ * }
+ * + *

See {@link L2CacheRampSimulation} for the ramp model and available properties. + * + * @author Morten Svanæs + */ +public class L2CacheReadHeavyRampTest extends L2CacheRampSimulation { + + public L2CacheReadHeavyRampTest() { + install(L2CacheRampSimulation::reads); + } +} diff --git a/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheTrackerImportRampTest.java b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheTrackerImportRampTest.java new file mode 100644 index 000000000000..bdef4b2b6c30 --- /dev/null +++ b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheTrackerImportRampTest.java @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.test.platform; + +import static io.gatling.javaapi.core.CoreDsl.StringBody; +import static io.gatling.javaapi.core.CoreDsl.exec; +import static io.gatling.javaapi.core.CoreDsl.jsonPath; +import static io.gatling.javaapi.core.CoreDsl.listFeeder; +import static io.gatling.javaapi.http.HttpDsl.http; +import static io.gatling.javaapi.http.HttpDsl.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.gatling.javaapi.core.ChainBuilder; +import io.gatling.javaapi.core.FeederBuilder; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Tracker-import ramp simulation: the second level cache under concurrent event imports. + * + *

Concurrent {@code POST /api/tracker?async=false} of synthetic events against an option-heavy + * event program (default: Inpatient morbidity and mortality {@code eBAyeGv0exc}, Sierra Leone demo + * DB), mixed with capture-style metadata reads. Event data values are drawn randomly from the + * program's real option sets, fetched at simulation start (option-set sizes are logged so every run + * records how option-heavy the workload actually is). The import path repeatedly query-loads + * reference metadata (options, option sets, data elements, org units), which under READ_WRITE L2 + * regions takes the region write lock per hydrated row -- the convoy mechanism this workload exists + * to measure. + * + *

Sync vs async: the import is measured SYNCHRONOUSLY ({@code async=false}) so request latency + * attributes the full import transaction cost (validation, preheat, persistence) to the request. + * Uganda's pipeline uses the async default, but async would only measure job-enqueue latency, which + * is useless for region-lock attribution. The DB starts fresh per harness run (seeded volume), so + * within-run growth is part of the workload and identical across compared runs. + * + *

Additional properties on top of {@link L2CacheRampSimulation}: + * + *

    + *
  • {@code trackerProgramUid} (default: {@code eBAyeGv0exc}) event program under load + *
  • {@code importMode} (default: {@code single}): {@code single} = capture-style one event per + * POST; {@code batch} = Uganda-style sync payloads of {@code eventsPerRequest} events + *
  • {@code eventsPerRequest} (default: {@code 100}) events per POST in {@code batch} mode + *
+ * + * @author Morten Svanæs + */ +public class L2CacheTrackerImportRampTest extends L2CacheRampSimulation { + + private static final String PROGRAM_UID = prop("trackerProgramUid", "eBAyeGv0exc"); + private static final String IMPORT_MODE = prop("importMode", "single"); + private static final int EVENTS_PER_REQUEST = Integer.parseInt(prop("eventsPerRequest", "100")); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** One data element of the program stage plus the value pool to draw from. */ + private record DataElementSpec(String uid, String valueType, List optionCodes) {} + + private record ProgramSpec( + String programUid, + String stageUid, + List orgUnits, + List elements, + List optionSetUids) {} + + private static final ProgramSpec PROGRAM = fetchProgramSpec(); + + /** Option set UIDs of the program, fed into capture-style option-set reads. */ + private static final FeederBuilder OPTION_SET_FEEDER = optionSetFeeder(); + + public L2CacheTrackerImportRampTest() { + install(L2CacheTrackerImportRampTest::workflow); + } + + private static ChainBuilder workflow(String p) { + // Capture-style reads: the metadata the Capture app hits while users enter events. The + // option-set read hammers the Option/OptionSet regions the import path also touches. + // + // Every check asserts on the JSON body, not just the HTTP status: a collapsed server can + // fail logins with 500s, after which sessionless virtual users are redirected to the login + // page, which answers 200 text/html in milliseconds. Status-only checks count those bounces + // as fast successes and corrupt the late ramp steps of a baseline run. + ChainBuilder reads = + exec(http(p + " me") + .get("/api/me") + .check(status().is(200), jsonPath("$.id").find().exists())) + .feed(OPTION_SET_FEEDER) + .exec( + http(p + " optionSet byId") + .get("/api/optionSets/#{osUid}") + .queryParam("fields", "id,name,valueType,options[id,name,code]") + .check(status().is(200), jsonPath("$.id").find().exists())) + .exec( + http(p + " events workingList") + .get("/api/tracker/events") + .queryParam("program", PROGRAM.programUid()) + .queryParam("orgUnit", ORG_UNIT_UID) + .queryParam("orgUnitMode", "DESCENDANTS") + .queryParam("order", "occurredAt:desc") + .queryParam("pageSize", "25") + // response shape differs across versions; any parseable JSON root proves + // this is a real API response and not a login bounce + .check(status().is(200), jsonPath("$").find().exists())); + + int eventsPerPost = "batch".equalsIgnoreCase(IMPORT_MODE) ? EVENTS_PER_REQUEST : 1; + String importName = + p + (eventsPerPost == 1 ? " tracker import single" : " tracker import batch"); + ChainBuilder importEvents = + exec( + http(importName) + .post("/api/tracker") + .queryParam("async", "false") + .header("Content-Type", "application/json") + .body(StringBody(session -> eventsPayload(eventsPerPost))) + // a synchronous import that worked reports status OK in the body + .check(status().is(200), jsonPath("$.status").is("OK"))); + + return reads.exec(importEvents); + } + + // ------------------------------------------------------------------------- + // Synthetic event payloads + // ------------------------------------------------------------------------- + + private static String eventsPayload(int eventCount) { + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode events = root.putArray("events"); + for (int i = 0; i < eventCount; i++) { + events.add(randomEvent()); + } + return root.toString(); + } + + private static ObjectNode randomEvent() { + ThreadLocalRandom random = ThreadLocalRandom.current(); + ObjectNode event = MAPPER.createObjectNode(); + event.put("program", PROGRAM.programUid()); + event.put("programStage", PROGRAM.stageUid()); + event.put("orgUnit", PROGRAM.orgUnits().get(random.nextInt(PROGRAM.orgUnits().size()))); + event.put("status", "ACTIVE"); + event.put("occurredAt", LocalDate.now().minusDays(random.nextInt(90)).toString()); + ArrayNode dataValues = event.putArray("dataValues"); + for (DataElementSpec spec : PROGRAM.elements()) { + String value = randomValue(spec, random); + if (value == null) { + continue; + } + ObjectNode dataValue = dataValues.addObject(); + dataValue.put("dataElement", spec.uid()); + dataValue.put("value", value); + } + return event; + } + + private static String randomValue(DataElementSpec spec, ThreadLocalRandom random) { + if (!spec.optionCodes().isEmpty()) { + return spec.optionCodes().get(random.nextInt(spec.optionCodes().size())); + } + return switch (spec.valueType()) { + case "INTEGER", "INTEGER_POSITIVE", "INTEGER_ZERO_OR_POSITIVE" -> + String.valueOf(random.nextInt(1, 99)); + case "INTEGER_NEGATIVE" -> String.valueOf(-random.nextInt(1, 99)); + case "NUMBER", "PERCENTAGE" -> String.valueOf(random.nextInt(1, 99)) + ".5"; + case "UNIT_INTERVAL" -> "0." + random.nextInt(1, 9); + case "BOOLEAN" -> String.valueOf(random.nextBoolean()); + case "TRUE_ONLY" -> "true"; + case "DATE" -> LocalDate.now().minusDays(random.nextInt(365)).toString(); + case "DATETIME" -> LocalDate.now().minusDays(random.nextInt(365)) + "T10:00:00.000"; + case "TIME" -> "10:30"; + case "PHONE_NUMBER" -> "+4712345678"; + case "EMAIL" -> "perf-test@example.com"; + case "TEXT", "LONG_TEXT" -> "l2 perf " + random.nextInt(1_000_000); + // FILE_RESOURCE, IMAGE, COORDINATE, ORGANISATION_UNIT, USERNAME... are skipped + default -> null; + }; + } + + // ------------------------------------------------------------------------- + // Program metadata fetched once at simulation start + // ------------------------------------------------------------------------- + + private static ProgramSpec fetchProgramSpec() { + String url = + BASE_URL + + "/api/programs/" + + PROGRAM_UID + + ".json?fields=id,name,programType,categoryCombo[id,name],organisationUnits[id]," + + "programStages[id,name,programStageDataElements[dataElement[id,name,valueType," + + "optionSet[id,name,options[code]]]]]"; + JsonNode program = getJson(url); + + if (!"WITHOUT_REGISTRATION".equals(program.path("programType").asText())) { + throw new IllegalStateException( + "Program " + PROGRAM_UID + " is not an event program: " + program.path("programType")); + } + JsonNode stages = program.path("programStages"); + if (stages.size() != 1) { + throw new IllegalStateException( + "Expected exactly one program stage on " + PROGRAM_UID + ", found " + stages.size()); + } + JsonNode stage = stages.get(0); + + List orgUnits = new ArrayList<>(); + for (JsonNode orgUnit : program.path("organisationUnits")) { + orgUnits.add(orgUnit.path("id").asText()); + } + if (orgUnits.isEmpty()) { + throw new IllegalStateException("Program " + PROGRAM_UID + " has no org units assigned"); + } + + List elements = new ArrayList<>(); + List optionSetUids = new ArrayList<>(); + int totalOptions = 0; + for (JsonNode psde : stage.path("programStageDataElements")) { + JsonNode dataElement = psde.path("dataElement"); + List optionCodes = new ArrayList<>(); + JsonNode optionSet = dataElement.path("optionSet"); + if (!optionSet.isMissingNode()) { + for (JsonNode option : optionSet.path("options")) { + optionCodes.add(option.path("code").asText()); + } + if (!optionCodes.isEmpty()) { + if (!optionSetUids.contains(optionSet.path("id").asText())) { + optionSetUids.add(optionSet.path("id").asText()); + } + totalOptions += optionCodes.size(); + System.out.println( + "[L2CacheTrackerImportRampTest] optionSet " + + optionSet.path("name").asText() + + " (" + + optionSet.path("id").asText() + + ") size=" + + optionCodes.size() + + " on dataElement " + + dataElement.path("name").asText()); + } + } + elements.add( + new DataElementSpec( + dataElement.path("id").asText(), + dataElement.path("valueType").asText(), + List.copyOf(optionCodes))); + } + if (elements.isEmpty()) { + throw new IllegalStateException("Program stage of " + PROGRAM_UID + " has no data elements"); + } + + System.out.println( + "[L2CacheTrackerImportRampTest] program " + + program.path("name").asText() + + " (" + + PROGRAM_UID + + "): stage " + + stage.path("id").asText() + + ", " + + elements.size() + + " data elements, " + + optionSetUids.size() + + " distinct option sets with " + + totalOptions + + " options total, " + + orgUnits.size() + + " org units, categoryCombo=" + + program.path("categoryCombo").path("name").asText() + + ", importMode=" + + IMPORT_MODE + + (("batch".equalsIgnoreCase(IMPORT_MODE)) + ? " (" + EVENTS_PER_REQUEST + " events/request)" + : "")); + + return new ProgramSpec( + program.path("id").asText(), + stage.path("id").asText(), + List.copyOf(orgUnits), + List.copyOf(elements), + List.copyOf(optionSetUids)); + } + + private static FeederBuilder optionSetFeeder() { + if (PROGRAM.optionSetUids().isEmpty()) { + throw new IllegalStateException( + "Program " + PROGRAM_UID + " has no option sets; the option-heavy workload needs them"); + } + List> records = new ArrayList<>(); + for (String osUid : PROGRAM.optionSetUids()) { + records.add(Map.of("osUid", osUid)); + } + return listFeeder(records).random(); + } + + private static JsonNode getJson(String url) { + try { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Basic " + BASIC_AUTH) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IllegalStateException(url + " failed with HTTP " + response.statusCode()); + } + return MAPPER.readTree(response.body()); + } catch (IOException e) { + throw new IllegalStateException("Failed to fetch " + url, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while fetching " + url, e); + } + } +} diff --git a/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheWriteMixedRampTest.java b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheWriteMixedRampTest.java new file mode 100644 index 000000000000..25e6c92c5d13 --- /dev/null +++ b/dhis-2/dhis-test-performance/src/test/java/org/hisp/dhis/test/platform/L2CacheWriteMixedRampTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.test.platform; + +import static io.gatling.javaapi.core.CoreDsl.StringBody; +import static io.gatling.javaapi.core.CoreDsl.exec; +import static io.gatling.javaapi.core.CoreDsl.jsonPath; +import static io.gatling.javaapi.core.CoreDsl.percent; +import static io.gatling.javaapi.http.HttpDsl.http; +import static io.gatling.javaapi.http.HttpDsl.status; + +import io.gatling.javaapi.core.ChainBuilder; + +/** + * Write-mixed concurrency ramp for the L2 cache baseline. + * + *

Same hot-metadata read chain as {@link L2CacheReadHeavyRampTest}, but a configurable share of + * workflow iterations (default {@code writePercent=5}) additionally issues a metadata write: a JSON + * Patch on a random data element's description. Each write invalidates the DataElement entry under + * the region write lock AND puts into {@code default-update-timestamps-region} (the query-cache + * write tax), invalidating every cached query on the dataelement table while readers hammer the + * same regions. This is the READ_WRITE lock-convoy pressure scenario. + * + *

Concurrent patches of the same UID can race in the metadata import; HTTP 409 is accepted on + * the write so the run keeps measuring instead of failing (the write rate is what matters, not + * per-write success). + * + *

{@code
+ * DHIS2_IMAGE=dhis2/core-l2truth:local \
+ * DHIS_CONF_FILE=dhis-l2cache-on.conf \
+ * SIMULATION_CLASS=org.hisp.dhis.test.platform.L2CacheWriteMixedRampTest \
+ * ./run-simulation.sh
+ * }
+ * + *

See {@link L2CacheRampSimulation} for the ramp model and available properties. + * + * @author Morten Svanæs + */ +public class L2CacheWriteMixedRampTest extends L2CacheRampSimulation { + + public L2CacheWriteMixedRampTest() { + install(L2CacheWriteMixedRampTest::readsWithWrites); + } + + private static ChainBuilder readsWithWrites(String p) { + ChainBuilder write = + exec( + http(p + " PATCH dataElement") + .patch("/api/dataElements/#{deUid}") + .header("Content-Type", "application/json-patch+json") + .body( + StringBody( + "[{\"op\":\"replace\",\"path\":\"/description\"," + + "\"value\":\"perf test #{randomUuid()}\"}]")) + .check(status().in(200, 409), jsonPath("$").find().exists())); + + return exec(reads(p)).randomSwitch().on(percent(WRITE_PERCENT).then(write)); + } +}