Skip to content

Commit 5f6a524

Browse files
netromsjason-p-pickeringclaude
authored
fix: avoid loading UserRole members during JSON Patch apply [DHIS2-21852] (#24489)
* fix: avoid loading UserRole members during JSON Patch apply JsonPatchManager converted the managed entity to a tree via valueToTree and then re-invoked every collection getter in handleCollectionUpdates. For UserRole, getUsers() initializes all lazy members, causing O(members) SQL and hundreds of MB of allocation on scalar PATCHes. Skip non-owner collection properties that no patch path references, in both serialization passes. Non-owner collections are ignored by metadata import UPDATE, so omitting them is semantically free; owner collections always stay in the tree (omitting them would clear them on import). Non-owner collections referenced by patch paths keep today's behavior. * test: add UserRoles PATCH performance simulation Gatling simulation patching a scalar field on an empty control role and on a large-membership role (platform-perf DB), making the O(members) PATCH regression visible. No calibrated thresholds yet; asserts 100% success only. * fix: skip non-persisted derived props during JSON Patch serialize OrganisationUnit.leaf calls children.isEmpty(), which initializes the inverse children collection even when JsonPatchManager already omits non-owner collections. Exclude unreferenced non-persisted properties from patch serialization so derived getters cannot force lazy loads. * test: OU scalar JSON patch must not init inverse collections * test: User scalar JSON patch skips userGroups, keeps userRoles * test: UserGroup scalar JSON patch keeps owner users * test: DataElementGroup name patch preserves owner members * test: web-api JSON patch side-effect coverage across entities * refactor: rework JsonPatch collection-exclusion filter to match FieldFilterService pattern [DHIS2-21852] Addresses review feedback on #24489 (Jan Bernitt): the per-call `.addMixIn(realClass, ...)` + inline mixin + filter-provider wiring in JsonPatchManager.apply() was rebuilt from scratch on every call and didn't follow any existing convention. Reworks it to mirror the codebase's established pattern for the same underlying problem -- skipping a getter during Jackson serialization based on per-call criteria, without invoking it -- already used for the `?fields=` GET path (org.hisp.dhis.fieldfiltering.FieldFilterService/FieldFilterMixin). - New `JsonPatchFilterMixin` (`@JsonFilter`) and `JsonPatchExcludedPropertyFilter` (`SimpleBeanPropertyFilter`), shaped like `FieldFilterMixin`/`FieldFilterSimpleBeanPropertyFilter`. - `JsonPatchManager` caches one mixin-bound `ObjectMapper` copy per entity type (`ConcurrentHashMap<Class<?>, ObjectMapper>`, built lazily via `computeIfAbsent`) instead of rebuilding the mixin binding on every `apply()` call. `findExcludableNonOwnerCollections` (the exclusion rule itself) is unchanged -- it was already schema-driven and generic. - The mixin is scoped per-`realClass`, not bound to `Object.class` globally: `FieldFilterMixin`'s own filter is path-aware, so a global binding is safe for it, but `JsonPatchExcludedPropertyFilter` matches by property name only. A global binding was tried first and found, in review, to silently strip `Sharing.users`/`Sharing.userGroups` (present on every `IdentifiableObject`) whenever a same-named top-level collection was excluded, e.g. on a scalar UserRole PATCH. Scoping per-class makes that cross-type collision impossible. Regression test: `testUserRoleSharingUsersSurviveScalarPatch`. - Answers Jan's second question (should an explicit patch to an excluded property throw?): no -- `findExcludableNonOwnerCollections` already un-excludes a property referenced by a patch path, so it falls back to full (slower, correct) hydration rather than being silently dropped. Replaced the previous `testUserRoleUsersPathPatchDoesNotThrow` (asserted only `assertNotNull`, would pass either way) with `testUserRoleUsersPathPatchFallsBackToFullHydration`, which asserts `Hibernate.isInitialized(...)` actually flips to `true`, mutation-tested against the fallback logic. No functional change to PATCH behavior beyond fixing the Object.class regression introduced and caught within this same review cycle (never released). All existing JsonPatchManagerTest coverage (14 tests) plus the new JsonPatchExcludedPropertyFilterTest (2 tests) pass; UserControllerTest (52 tests, dhis-test-web-api) unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * style: spotless JsonPatchSideEffectControllerTest * test: calibrate UserRolesPerformanceTest p95/max thresholds Calibrated from the baseline/candidate A/B run on the platform-perf DB used to recalibrate PR #24489 after the rebase onto the JsonPatchFilterMixin refactor. PATCH scenarios share one threshold since the invariant under test is that PATCH latency must not depend on role membership size. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor: make JsonPatch filter and mixin package-private [DHIS2-21852] Per review: both are internal to JsonPatchManager and not reused outside the package, so drop public visibility. --------- Co-authored-by: Jason Pickering <jason@dhis2.org> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c53415d commit 5f6a524

8 files changed

Lines changed: 1115 additions & 3 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright (c) 2004-2022, 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.jsonpatch;
31+
32+
import com.fasterxml.jackson.core.JsonGenerator;
33+
import com.fasterxml.jackson.databind.SerializerProvider;
34+
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
35+
import com.fasterxml.jackson.databind.ser.PropertyWriter;
36+
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
37+
import java.util.Set;
38+
39+
/**
40+
* PropertyFilter that omits properties named in {@code excluded} without invoking their getter, via
41+
* {@link PropertyWriter#serializeAsOmittedField}. Mirrors {@code
42+
* org.hisp.dhis.fieldfiltering.FieldFilterSimpleBeanPropertyFilter}'s shape, simplified to a flat
43+
* excluded-name set (no path-context tracking needed here).
44+
*
45+
* @author Jason Pickering
46+
*/
47+
class JsonPatchExcludedPropertyFilter extends SimpleBeanPropertyFilter {
48+
49+
/**
50+
* Single source of truth for the filter id -- referenced by both {@link JsonPatchFilterMixin}'s
51+
* {@code @JsonFilter} annotation and {@link JsonPatchManager}'s {@code addFilter} call. Declared
52+
* here, on a concrete class, rather than on the mixin interface itself: an interface holding only
53+
* a constant is exactly the "constant interface" anti-pattern (SonarQube java:S1214) --
54+
* implementing classes would inherit the constant into their own namespace for no reason. {@code
55+
* JsonPatchFilterMixin} stays a truly empty marker interface, matching its precedent, {@code
56+
* org.hisp.dhis.fieldfiltering.FieldFilterMixin}.
57+
*/
58+
static final String ID = "json-patch-collection-filter";
59+
60+
private final Set<String> excluded;
61+
62+
JsonPatchExcludedPropertyFilter(Set<String> excluded) {
63+
this.excluded = excluded;
64+
}
65+
66+
@Override
67+
protected boolean include(final BeanPropertyWriter writer) {
68+
return true;
69+
}
70+
71+
@Override
72+
protected boolean include(final PropertyWriter writer) {
73+
return true;
74+
}
75+
76+
@Override
77+
public void serializeAsField(
78+
Object pojo, JsonGenerator jgen, SerializerProvider provider, PropertyWriter writer)
79+
throws Exception {
80+
if (excluded.contains(writer.getName())) {
81+
if (!jgen.canOmitFields()) {
82+
writer.serializeAsOmittedField(pojo, jgen, provider);
83+
}
84+
} else {
85+
writer.serializeAsField(pojo, jgen, provider);
86+
}
87+
}
88+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (c) 2004-2022, 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.jsonpatch;
31+
32+
import com.fasterxml.jackson.annotation.JsonFilter;
33+
34+
/**
35+
* Bound per patched entity type ({@code realClass}), not {@code Object.class} -- see {@link
36+
* JsonPatchManager}'s {@code patchMapperCache} field for why a global binding is wrong: {@code
37+
* JsonPatchExcludedPropertyFilter} matches by property name only, so binding this mixin to {@code
38+
* Object.class} would apply the exclusion filter to every nested object in the graph too (e.g.
39+
* {@code org.hisp.dhis.user.sharing.Sharing.users}, which collides by name with {@code
40+
* UserRole.users}) and silently strip unrelated data. {@code JsonPatchManager} builds one
41+
* mixin-bound {@code ObjectMapper} copy per distinct {@code realClass}, lazily, and caches it.
42+
*
43+
* <p>Solves the same underlying problem as {@code org.hisp.dhis.fieldfiltering.FieldFilterMixin}
44+
* (skip a getter during Jackson serialization based on per-call criteria, without invoking it) for
45+
* the {@code ?fields=} GET path -- but that mixin's filter is path-aware, which is what makes its
46+
* own {@code Object.class}-wide binding safe; this one is not, so it must stay scoped per-class.
47+
*
48+
* @author Jason Pickering
49+
*/
50+
@JsonFilter(JsonPatchExcludedPropertyFilter.ID)
51+
interface JsonPatchFilterMixin {}

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/jsonpatch/JsonPatchManager.java

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@
3636
import com.fasterxml.jackson.databind.ObjectMapper;
3737
import com.fasterxml.jackson.databind.node.ArrayNode;
3838
import com.fasterxml.jackson.databind.node.ObjectNode;
39+
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
3940
import java.util.Collection;
41+
import java.util.HashSet;
42+
import java.util.Map;
43+
import java.util.Set;
44+
import java.util.concurrent.ConcurrentHashMap;
45+
import java.util.stream.Collectors;
4046
import org.hisp.dhis.common.BaseIdentifiableObject;
4147
import org.hisp.dhis.common.EmbeddedObject;
4248
import org.hisp.dhis.common.IdentifiableObject;
@@ -59,8 +65,32 @@
5965
*/
6066
@Service
6167
public class JsonPatchManager {
68+
6269
private final ObjectMapper jsonMapper;
6370

71+
/**
72+
* Cache of per-entity-type {@code ObjectMapper} copies with {@link JsonPatchFilterMixin} bound to
73+
* that type only -- NOT {@code Object.class}. Binding the mixin globally would apply the
74+
* exclusion filter to every nested object in the serialized graph too: {@code Sharing} (present
75+
* on every {@code IdentifiableObject} via {@code BaseIdentifiableObject.getSharing()}) has its
76+
* own {@code @JsonProperty} field literally named {@code users}, so a global binding would
77+
* silently strip {@code sharing.users}/{@code sharing.userGroups} whenever a same-named top-level
78+
* collection (e.g. {@code UserRole.users}) is excluded -- confirmed as a real bug during review,
79+
* not a hypothetical. Scoping to {@code realClass} makes that specific cross-type collision
80+
* impossible: only {@code realClass} carries {@code @JsonFilter}, so a differently-typed nested
81+
* object (e.g. {@code Sharing}) is never affected by another type's exclusions. (A
82+
* self-referential {@code realClass}, e.g. a nested {@code OrganisationUnit} under another {@code
83+
* OrganisationUnit}, does re-carry the filter -- but the excluded set only ever names {@code
84+
* realClass}'s own non-owner collections, so re-applying it at any depth is safe by the same
85+
* owner/non-owner invariant that makes dropping them at the root safe.) Built lazily, once per
86+
* distinct {@code realClass} ever patched, then reused -- mirrors {@code
87+
* org.hisp.dhis.fieldfiltering.FieldFilterSimpleBeanPropertyFilter}'s own {@code
88+
* ALWAYS_EXPAND_CACHE} pattern for the same "compute once per Class" idiom. Per-call code (see
89+
* {@link #toJsonNode}) only attaches a fresh {@link SimpleFilterProvider}; it does not rebuild
90+
* the cached entry on every patch.
91+
*/
92+
private final Map<Class<?>, ObjectMapper> patchMapperCache = new ConcurrentHashMap<>();
93+
6494
private final SchemaService schemaService;
6595

6696
public JsonPatchManager(ObjectMapper jsonMapper, SchemaService schemaService) {
@@ -73,6 +103,16 @@ public JsonPatchManager(ObjectMapper jsonMapper, SchemaService schemaService) {
73103
* object into a tree like node structure, and this is where the patch will be applied. This means
74104
* that any property renaming etc will be followed.
75105
*
106+
* <p>Non-owner collection properties that are not referenced by any patch path are omitted from
107+
* serialization. Non-owner collections are ignored by metadata import UPDATE, so omitting them is
108+
* free; omitting owner collections would clear them on import. Non-persisted derived properties
109+
* (for example {@code OrganisationUnit.leaf}) are also omitted when unreferenced, because their
110+
* getters can force-initialize inverse lazy collections. Skipping avoids initializing lazy
111+
* Hibernate collections such as {@code UserRole.members} during scalar PATCH /userRoles (slow
112+
* PATCH /userRoles). A patch path that explicitly targets an otherwise-excludable property (e.g.
113+
* {@code /users}) removes it from the excluded set, so that property still falls back to full
114+
* (slower, but correct) hydration -- it is never silently dropped.
115+
*
76116
* @param patch JsonPatch object with the operations it should apply.
77117
* @param object Jackson Object to apply the patch to.
78118
* @return New instance of the object with the patch applied.
@@ -86,12 +126,19 @@ public <T> T apply(JsonPatch patch, T object) throws JsonPatchException {
86126

87127
Class<?> realClass = HibernateProxyUtils.getRealClass(object);
88128
Schema schema = schemaService.getSchema(realClass);
89-
JsonNode node = jsonMapper.valueToTree(object);
129+
130+
Set<String> patchedPaths =
131+
patch.getOperations().stream()
132+
.map(op -> op.getPath().getMatchingProperty())
133+
.collect(Collectors.toSet());
134+
Set<String> excluded = findExcludableProperties(schema, patchedPaths);
135+
136+
JsonNode node = toJsonNode(object, realClass, excluded);
90137

91138
// since valueToTree does not properly handle our deeply nested classes,
92139
// we need to make another trip to make sure all collections are
93140
// correctly made into json nodes.
94-
handleCollectionUpdates(object, schema, (ObjectNode) node);
141+
handleCollectionUpdates(object, schema, (ObjectNode) node, excluded);
95142

96143
validatePatchPath(patch, schema);
97144

@@ -100,10 +147,81 @@ public <T> T apply(JsonPatch patch, T object) throws JsonPatchException {
100147
jsonToObject(node, realClass, jsonMapper, ex -> new JsonPatchException(ex.getMessage()));
101148
}
102149

103-
private <T> void handleCollectionUpdates(T object, Schema schema, ObjectNode node) {
150+
/**
151+
* Collect JSON field names that are safe to omit during patch serialization.
152+
*
153+
* <p>Include when no patch path references {@link Property#getName()} or {@link
154+
* Property#getCollectionName()}, and either:
155+
*
156+
* <ul>
157+
* <li>{@code isCollection() && !isOwner()} - non-owner collections are ignored by metadata
158+
* import UPDATE
159+
* <li>{@code !isPersisted()} - derived getters must not run (e.g. {@code leaf} calling {@code
160+
* children.isEmpty()})
161+
* </ul>
162+
*
163+
* <p>Owner collections must always remain serialized (metadata import UPDATE would wipe them if
164+
* omitted). Properties referenced by a patch path keep today's behavior.
165+
*/
166+
private static Set<String> findExcludableProperties(Schema schema, Set<String> patchedPaths) {
167+
Set<String> excluded = new HashSet<>();
168+
for (Property property : schema.getProperties()) {
169+
if (isReferencedByPatch(property, patchedPaths)) {
170+
continue;
171+
}
172+
if (property.isCollection() && !property.isOwner()) {
173+
// collectionName is the JSON name Jackson serializes (e.g. "users")
174+
excluded.add(property.getCollectionName());
175+
} else if (!property.isPersisted()) {
176+
String jsonName =
177+
property.isCollection() ? property.getCollectionName() : property.getName();
178+
if (jsonName != null) {
179+
excluded.add(jsonName);
180+
}
181+
}
182+
}
183+
return excluded;
184+
}
185+
186+
private static boolean isReferencedByPatch(Property property, Set<String> patchedPaths) {
187+
return patchedPaths.contains(property.getName())
188+
|| (property.getCollectionName() != null
189+
&& patchedPaths.contains(property.getCollectionName()));
190+
}
191+
192+
/**
193+
* Builds the JSON tree for {@code object}, skipping getters for properties in {@code excluded}.
194+
* Looks up (or lazily builds) {@code realClass}'s cached mapper from {@link #patchMapperCache};
195+
* only attaches a fresh, per-call {@link SimpleFilterProvider} -- the expensive
196+
* mixin-binding/{@code copy()} happens at most once per distinct {@code realClass}, not on every
197+
* patch.
198+
*/
199+
private JsonNode toJsonNode(Object object, Class<?> realClass, Set<String> excluded) {
200+
if (excluded.isEmpty()) {
201+
return jsonMapper.valueToTree(object);
202+
}
203+
204+
ObjectMapper patchMapper =
205+
patchMapperCache.computeIfAbsent(
206+
realClass, cls -> jsonMapper.copy().addMixIn(cls, JsonPatchFilterMixin.class));
207+
208+
SimpleFilterProvider filterProvider =
209+
new SimpleFilterProvider()
210+
.addFilter(
211+
JsonPatchExcludedPropertyFilter.ID, new JsonPatchExcludedPropertyFilter(excluded));
212+
return patchMapper.copy().setFilterProvider(filterProvider).valueToTree(object);
213+
}
214+
215+
private <T> void handleCollectionUpdates(
216+
T object, Schema schema, ObjectNode node, Set<String> excluded) {
104217
for (Property property : schema.getProperties()) {
105218

106219
if (property.isCollection()) {
220+
// Skip before safeInvoke so excluded lazy collections stay uninitialized.
221+
if (excluded.contains(property.getCollectionName())) {
222+
continue;
223+
}
224+
107225
Object data = safeInvoke(object, property.getGetterMethod());
108226

109227
Collection<?> collection = (Collection<?>) data;

0 commit comments

Comments
 (0)