From 81e5b5b03f96c5eeeca8d4fd5eb1d26ebd489779 Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Sun, 31 May 2026 11:28:13 +0800 Subject: [PATCH 1/3] fix: make RenderingObject Serializable for Ehcache L2 cache Ehcache 3.12.0 uses SerializingCopier which requires Java serialization of cached entity state. RenderingObject is stored as DeviceRenderTypeMap values in JSONB columns on the L2-cached ProgramSection and ProgramStageSection entities, but lacked Serializable, which would break cache serialization. Also adds CachedEntityJsonbSerializableTest, a metamodel-driven guard asserting every JSONB value type on an L2-cached entity is Serializable, so this cannot regress silently. Co-authored-by: Claude Opus 4.8 (1M context) --- .../dhis/render/type/RenderingObject.java | 4 +- .../CachedEntityJsonbSerializableTest.java | 186 ++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/render/type/RenderingObject.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/render/type/RenderingObject.java index 7b4bfe634330..4616896c97be 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/render/type/RenderingObject.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/render/type/RenderingObject.java @@ -29,7 +29,9 @@ */ package org.hisp.dhis.render.type; -public interface RenderingObject> { +import java.io.Serializable; + +public interface RenderingObject> extends Serializable { String _TYPE = "type"; T getType(); diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java new file mode 100644 index 000000000000..d94866e45fc2 --- /dev/null +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2004-2025, 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.persistence.EntityManagerFactory; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.metamodel.spi.MetamodelImplementor; +import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.type.ComponentType; +import org.hibernate.type.CustomType; +import org.hibernate.type.Type; +import org.hibernate.usertype.UserType; +import org.hisp.dhis.cache.CachedEntityJsonbSerializableTest.DhisConfig; +import org.hisp.dhis.hibernate.jsonb.type.JsonBinaryType; +import org.hisp.dhis.render.type.RenderingObject; +import org.hisp.dhis.test.config.PostgresTestConfigOverride; +import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; +import org.junit.jupiter.api.DisplayName; +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; + +/** + * Guard test for the Ehcache L2 second-level cache. + * + *

Since the Ehcache bump to 3.12 (see #23834 / #24002) the on-heap store uses {@code + * SerializingCopier}, which performs real Java serialization of cached entity state. JSONB columns + * are mapped with {@link JsonBinaryType} and its subclasses, whose {@code disassemble()} stores the + * deep-copied Java value object (or, for list/set types, its elements) directly in the cache entry. + * Ehcache then serializes that object graph, so every JSONB value type living on an L2-cached + * entity MUST implement {@link Serializable} (a missing one throws {@code NotSerializableException} + * at runtime, e.g. the {@code ApiTokenAttribute} regression fixed in #24002). + * + *

This test walks the live Hibernate metamodel rather than a hard-coded list, so any JSONB + * column added to a cached entity in the future is checked automatically. + * + * @author Morten Svanæs + */ +@ContextConfiguration(classes = {DhisConfig.class}) +class CachedEntityJsonbSerializableTest extends PostgresIntegrationTestBase { + + /** + * Enables the L2 cache so {@link EntityPersister#hasCache()} reflects the mapped cache regions. + */ + static class DhisConfig { + @Bean + public PostgresTestConfigOverride postgresTestConfigOverride() { + PostgresTestConfigOverride override = new PostgresTestConfigOverride(); + override.put(AvailableSettings.USE_SECOND_LEVEL_CACHE, "true"); + override.put("cache.ehcache.config.file", ""); + return override; + } + } + + @Autowired private EntityManagerFactory entityManagerFactory; + + @Test + @DisplayName("Every JSONB value type on an L2-cached entity must be java.io.Serializable") + void jsonbValueTypesOnCachedEntitiesAreSerializable() { + MetamodelImplementor metamodel = + entityManagerFactory.unwrap(SessionFactoryImplementor.class).getMetamodel(); + + Set> inspected = new TreeSet<>(Comparator.comparing(Class::getName)); + // value class -> list of "entity#property" locations where it is used + Map> violations = new TreeMap<>(); + + for (EntityPersister persister : metamodel.entityPersisters().values()) { + if (!persister.hasCache()) { + continue; + } + String entity = persister.getEntityName(); + String[] names = persister.getPropertyNames(); + Type[] types = persister.getPropertyTypes(); + for (int i = 0; i < types.length; i++) { + inspect(entity, names[i], types[i], inspected, violations); + } + } + + assertFalse( + inspected.isEmpty(), + "Sanity check failed: no JSONB value types were inspected on any cached entity. " + + "Is the second-level cache enabled for this context?"); + assertTrue(violations.isEmpty(), () -> buildFailureMessage(violations)); + } + + @Test + @DisplayName("RenderingObject must extend Serializable (cached as DeviceRenderTypeMap values)") + void renderingObjectIsSerializable() { + // DeviceRenderTypeMap (a LinkedHashMap) is itself Serializable, so the metamodel check above + // cannot see its RenderingObject values. ProgramSection and ProgramStageSection are L2-cached + // and store these maps, so the interface must extend Serializable for ehcache to serialize + // them. + assertTrue( + Serializable.class.isAssignableFrom(RenderingObject.class), + "RenderingObject is stored as the values of DeviceRenderTypeMap JSONB columns on L2-cached " + + "entities and must extend java.io.Serializable so the ehcache L2 cache can serialize it."); + } + + /** + * Recursively collects the value types of JSONB ({@link JsonBinaryType}) properties, descending + * into embedded components. For list/set JSONB types {@link UserType#returnedClass()} is the + * element type, which is exactly what must be serializable. + */ + private static void inspect( + String entity, + String property, + Type type, + Set> inspected, + Map> violations) { + if (type instanceof CustomType) { + UserType userType = ((CustomType) type).getUserType(); + if (userType instanceof JsonBinaryType) { + Class valueType = userType.returnedClass(); + inspected.add(valueType); + if (!Serializable.class.isAssignableFrom(valueType)) { + violations + .computeIfAbsent(valueType.getName(), k -> new ArrayList<>()) + .add(entity + "#" + property); + } + } + } else if (type instanceof ComponentType) { + ComponentType component = (ComponentType) type; + String[] subNames = component.getPropertyNames(); + Type[] subTypes = component.getSubtypes(); + for (int i = 0; i < subTypes.length; i++) { + inspect(entity, property + "." + subNames[i], subTypes[i], inspected, violations); + } + } + } + + private static String buildFailureMessage(Map> violations) { + StringBuilder sb = + new StringBuilder( + "Found JSONB value type(s) on L2-cached entities that do NOT implement " + + "java.io.Serializable.\nEhcache 3.12 (SerializingCopier) serializes cached entity " + + "state, so each of these (and its whole object graph) must implement Serializable:\n"); + violations.forEach( + (valueType, locations) -> + sb.append(" - ") + .append(valueType) + .append(" used by: ") + .append(String.join(", ", locations)) + .append('\n')); + return sb.toString(); + } +} From 97848eb6bbe71af4925f8a356dfa2edafdedbb0c Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 1 Jun 2026 01:45:34 +0800 Subject: [PATCH 2/3] fix: make CachedEntityJsonbSerializableTest pass CI Declare dhis-support-hibernate in dhis-test-integration so mvn dependency:analyze no longer fails on the used-undeclared JsonBinaryType import (broke unit-test and sonarqube). Skip untyped jbObject (java.lang.Object) columns in the guard test. Jackson materialises untyped JSONB into serializable JDK types (LinkedHashMap/ArrayList/String/Number/Boolean), so MapView#styleDataItem is cache-safe and was a false positive (broke integration-test). Co-Authored-By: Claude Opus 4.8 (1M context) --- dhis-2/dhis-test-integration/pom.xml | 5 +++++ .../dhis/cache/CachedEntityJsonbSerializableTest.java | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/dhis-2/dhis-test-integration/pom.xml b/dhis-2/dhis-test-integration/pom.xml index 5cea0a0faf64..d7dad422fd20 100644 --- a/dhis-2/dhis-test-integration/pom.xml +++ b/dhis-2/dhis-test-integration/pom.xml @@ -126,6 +126,11 @@ dhis-support-expression-parser compile + + org.hisp.dhis + dhis-support-hibernate + compile + org.hisp.dhis dhis-support-jdbc diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java index d94866e45fc2..7adda007ee85 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java @@ -151,6 +151,14 @@ private static void inspect( UserType userType = ((CustomType) type).getUserType(); if (userType instanceof JsonBinaryType) { Class valueType = userType.returnedClass(); + // Untyped JSONB columns (the "jbObject" type, returnedClass == java.lang.Object) are + // deserialized by Jackson into plain JDK types (LinkedHashMap, ArrayList, String, Number, + // Boolean), all of which are Serializable, so they are safe for the ehcache L2 cache. The + // declared Object type cannot be statically proven Serializable, so skip it to avoid a + // false positive (e.g. MapView#styleDataItem). + if (valueType == Object.class) { + return; + } inspected.add(valueType); if (!Serializable.class.isAssignableFrom(valueType)) { violations From ac416d2237c8a6a79f4b0b0fe27c22cd97c79eae Mon Sep 17 00:00:00 2001 From: Morten Svanaes Date: Mon, 1 Jun 2026 02:09:56 +0800 Subject: [PATCH 3/3] fix: use text block for cache guard test failure message Resolves SonarCloud code smell java:S6126 (the only new-code-smell failing the quality gate) by replacing the concatenated failure message with an equivalent text block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hisp/dhis/cache/CachedEntityJsonbSerializableTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java index 7adda007ee85..93fe22204b8c 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/cache/CachedEntityJsonbSerializableTest.java @@ -179,9 +179,10 @@ private static void inspect( private static String buildFailureMessage(Map> violations) { StringBuilder sb = new StringBuilder( - "Found JSONB value type(s) on L2-cached entities that do NOT implement " - + "java.io.Serializable.\nEhcache 3.12 (SerializingCopier) serializes cached entity " - + "state, so each of these (and its whole object graph) must implement Serializable:\n"); + """ + Found JSONB value type(s) on L2-cached entities that do NOT implement java.io.Serializable. + Ehcache 3.12 (SerializingCopier) serializes cached entity state, so each of these (and its whole object graph) must implement Serializable: + """); violations.forEach( (valueType, locations) -> sb.append(" - ")