Skip to content

Commit 19e4bbe

Browse files
authored
fix: make RenderingObject Serializable for Ehcache L2 cache (#24042)
1 parent b748998 commit 19e4bbe

3 files changed

Lines changed: 203 additions & 1 deletion

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/render/type/RenderingObject.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
*/
3030
package org.hisp.dhis.render.type;
3131

32-
public interface RenderingObject<T extends Enum<?>> {
32+
import java.io.Serializable;
33+
34+
public interface RenderingObject<T extends Enum<?>> extends Serializable {
3335
String _TYPE = "type";
3436

3537
T getType();

dhis-2/dhis-test-integration/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@
126126
<artifactId>dhis-support-expression-parser</artifactId>
127127
<scope>compile</scope>
128128
</dependency>
129+
<dependency>
130+
<groupId>org.hisp.dhis</groupId>
131+
<artifactId>dhis-support-hibernate</artifactId>
132+
<scope>compile</scope>
133+
</dependency>
129134
<dependency>
130135
<groupId>org.hisp.dhis</groupId>
131136
<artifactId>dhis-support-jdbc</artifactId>
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
* Copyright (c) 2004-2025, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.cache;
31+
32+
import static org.junit.jupiter.api.Assertions.assertFalse;
33+
import static org.junit.jupiter.api.Assertions.assertTrue;
34+
35+
import jakarta.persistence.EntityManagerFactory;
36+
import java.io.Serializable;
37+
import java.util.ArrayList;
38+
import java.util.Comparator;
39+
import java.util.List;
40+
import java.util.Map;
41+
import java.util.Set;
42+
import java.util.TreeMap;
43+
import java.util.TreeSet;
44+
import org.hibernate.cfg.AvailableSettings;
45+
import org.hibernate.engine.spi.SessionFactoryImplementor;
46+
import org.hibernate.metamodel.spi.MetamodelImplementor;
47+
import org.hibernate.persister.entity.EntityPersister;
48+
import org.hibernate.type.ComponentType;
49+
import org.hibernate.type.CustomType;
50+
import org.hibernate.type.Type;
51+
import org.hibernate.usertype.UserType;
52+
import org.hisp.dhis.cache.CachedEntityJsonbSerializableTest.DhisConfig;
53+
import org.hisp.dhis.hibernate.jsonb.type.JsonBinaryType;
54+
import org.hisp.dhis.render.type.RenderingObject;
55+
import org.hisp.dhis.test.config.PostgresTestConfigOverride;
56+
import org.hisp.dhis.test.integration.PostgresIntegrationTestBase;
57+
import org.junit.jupiter.api.DisplayName;
58+
import org.junit.jupiter.api.Test;
59+
import org.springframework.beans.factory.annotation.Autowired;
60+
import org.springframework.context.annotation.Bean;
61+
import org.springframework.test.context.ContextConfiguration;
62+
63+
/**
64+
* Guard test for the Ehcache L2 second-level cache.
65+
*
66+
* <p>Since the Ehcache bump to 3.12 (see #23834 / #24002) the on-heap store uses {@code
67+
* SerializingCopier}, which performs real Java serialization of cached entity state. JSONB columns
68+
* are mapped with {@link JsonBinaryType} and its subclasses, whose {@code disassemble()} stores the
69+
* deep-copied Java value object (or, for list/set types, its elements) directly in the cache entry.
70+
* Ehcache then serializes that object graph, so every JSONB value type living on an L2-cached
71+
* entity MUST implement {@link Serializable} (a missing one throws {@code NotSerializableException}
72+
* at runtime, e.g. the {@code ApiTokenAttribute} regression fixed in #24002).
73+
*
74+
* <p>This test walks the live Hibernate metamodel rather than a hard-coded list, so any JSONB
75+
* column added to a cached entity in the future is checked automatically.
76+
*
77+
* @author Morten Svanæs <msvanaes@dhis2.org>
78+
*/
79+
@ContextConfiguration(classes = {DhisConfig.class})
80+
class CachedEntityJsonbSerializableTest extends PostgresIntegrationTestBase {
81+
82+
/**
83+
* Enables the L2 cache so {@link EntityPersister#hasCache()} reflects the mapped cache regions.
84+
*/
85+
static class DhisConfig {
86+
@Bean
87+
public PostgresTestConfigOverride postgresTestConfigOverride() {
88+
PostgresTestConfigOverride override = new PostgresTestConfigOverride();
89+
override.put(AvailableSettings.USE_SECOND_LEVEL_CACHE, "true");
90+
override.put("cache.ehcache.config.file", "");
91+
return override;
92+
}
93+
}
94+
95+
@Autowired private EntityManagerFactory entityManagerFactory;
96+
97+
@Test
98+
@DisplayName("Every JSONB value type on an L2-cached entity must be java.io.Serializable")
99+
void jsonbValueTypesOnCachedEntitiesAreSerializable() {
100+
MetamodelImplementor metamodel =
101+
entityManagerFactory.unwrap(SessionFactoryImplementor.class).getMetamodel();
102+
103+
Set<Class<?>> inspected = new TreeSet<>(Comparator.comparing(Class::getName));
104+
// value class -> list of "entity#property" locations where it is used
105+
Map<String, List<String>> violations = new TreeMap<>();
106+
107+
for (EntityPersister persister : metamodel.entityPersisters().values()) {
108+
if (!persister.hasCache()) {
109+
continue;
110+
}
111+
String entity = persister.getEntityName();
112+
String[] names = persister.getPropertyNames();
113+
Type[] types = persister.getPropertyTypes();
114+
for (int i = 0; i < types.length; i++) {
115+
inspect(entity, names[i], types[i], inspected, violations);
116+
}
117+
}
118+
119+
assertFalse(
120+
inspected.isEmpty(),
121+
"Sanity check failed: no JSONB value types were inspected on any cached entity. "
122+
+ "Is the second-level cache enabled for this context?");
123+
assertTrue(violations.isEmpty(), () -> buildFailureMessage(violations));
124+
}
125+
126+
@Test
127+
@DisplayName("RenderingObject must extend Serializable (cached as DeviceRenderTypeMap values)")
128+
void renderingObjectIsSerializable() {
129+
// DeviceRenderTypeMap (a LinkedHashMap) is itself Serializable, so the metamodel check above
130+
// cannot see its RenderingObject values. ProgramSection and ProgramStageSection are L2-cached
131+
// and store these maps, so the interface must extend Serializable for ehcache to serialize
132+
// them.
133+
assertTrue(
134+
Serializable.class.isAssignableFrom(RenderingObject.class),
135+
"RenderingObject is stored as the values of DeviceRenderTypeMap JSONB columns on L2-cached "
136+
+ "entities and must extend java.io.Serializable so the ehcache L2 cache can serialize it.");
137+
}
138+
139+
/**
140+
* Recursively collects the value types of JSONB ({@link JsonBinaryType}) properties, descending
141+
* into embedded components. For list/set JSONB types {@link UserType#returnedClass()} is the
142+
* element type, which is exactly what must be serializable.
143+
*/
144+
private static void inspect(
145+
String entity,
146+
String property,
147+
Type type,
148+
Set<Class<?>> inspected,
149+
Map<String, List<String>> violations) {
150+
if (type instanceof CustomType) {
151+
UserType userType = ((CustomType) type).getUserType();
152+
if (userType instanceof JsonBinaryType) {
153+
Class<?> valueType = userType.returnedClass();
154+
// Untyped JSONB columns (the "jbObject" type, returnedClass == java.lang.Object) are
155+
// deserialized by Jackson into plain JDK types (LinkedHashMap, ArrayList, String, Number,
156+
// Boolean), all of which are Serializable, so they are safe for the ehcache L2 cache. The
157+
// declared Object type cannot be statically proven Serializable, so skip it to avoid a
158+
// false positive (e.g. MapView#styleDataItem).
159+
if (valueType == Object.class) {
160+
return;
161+
}
162+
inspected.add(valueType);
163+
if (!Serializable.class.isAssignableFrom(valueType)) {
164+
violations
165+
.computeIfAbsent(valueType.getName(), k -> new ArrayList<>())
166+
.add(entity + "#" + property);
167+
}
168+
}
169+
} else if (type instanceof ComponentType) {
170+
ComponentType component = (ComponentType) type;
171+
String[] subNames = component.getPropertyNames();
172+
Type[] subTypes = component.getSubtypes();
173+
for (int i = 0; i < subTypes.length; i++) {
174+
inspect(entity, property + "." + subNames[i], subTypes[i], inspected, violations);
175+
}
176+
}
177+
}
178+
179+
private static String buildFailureMessage(Map<String, List<String>> violations) {
180+
StringBuilder sb =
181+
new StringBuilder(
182+
"""
183+
Found JSONB value type(s) on L2-cached entities that do NOT implement java.io.Serializable.
184+
Ehcache 3.12 (SerializingCopier) serializes cached entity state, so each of these (and its whole object graph) must implement Serializable:
185+
""");
186+
violations.forEach(
187+
(valueType, locations) ->
188+
sb.append(" - ")
189+
.append(valueType)
190+
.append(" used by: ")
191+
.append(String.join(", ", locations))
192+
.append('\n'));
193+
return sb.toString();
194+
}
195+
}

0 commit comments

Comments
 (0)