Skip to content

Commit 4838a0f

Browse files
perf: stop tracker preheat from fully materializing OptionSet.options [2.42 port]
Ports #24850's fix from master to 2.42: preheat only the (option set, code) pairs actually referenced by an import payload via a new OptionValueSupplier, instead of OptionSetMapper forcing Hibernate to fully materialize every touched OptionSet.options collection (including JSONB attributevalues deserialization) regardless of how many option codes the import actually references. 2.42 predates master's tracker/single-event domain split, so the ported code uses 2.42's Event domain class instead of TrackerEvent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c5e20b1 commit 4838a0f

17 files changed

Lines changed: 832 additions & 74 deletions

File tree

dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/imports/bundle/DefaultTrackerBundleService.java

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2004-2022, University of Oslo
2+
* Copyright (c) 2004-2026, University of Oslo
33
* All rights reserved.
44
*
55
* Redistribution and use in source and binary forms, with or without
@@ -37,27 +37,39 @@
3737
import java.util.Date;
3838
import java.util.List;
3939
import java.util.Map;
40+
import java.util.Objects;
41+
import java.util.Set;
42+
import java.util.stream.Collectors;
4043
import javax.annotation.Nonnull;
4144
import lombok.RequiredArgsConstructor;
4245
import org.hibernate.Session;
4346
import org.hisp.dhis.common.UID;
47+
import org.hisp.dhis.dataelement.DataElement;
4448
import org.hisp.dhis.feedback.ForbiddenException;
4549
import org.hisp.dhis.feedback.NotFoundException;
4650
import org.hisp.dhis.program.UserInfoSnapshot;
51+
import org.hisp.dhis.trackedentity.TrackedEntityAttribute;
4752
import org.hisp.dhis.tracker.TrackerType;
4853
import org.hisp.dhis.tracker.imports.ParamsConverter;
4954
import org.hisp.dhis.tracker.imports.TrackerImportParams;
5055
import org.hisp.dhis.tracker.imports.bundle.persister.CommitService;
5156
import org.hisp.dhis.tracker.imports.bundle.persister.PersistenceException;
5257
import org.hisp.dhis.tracker.imports.bundle.persister.TrackerObjectDeletionService;
5358
import org.hisp.dhis.tracker.imports.bundle.persister.TrackerPersister;
59+
import org.hisp.dhis.tracker.imports.domain.Attribute;
60+
import org.hisp.dhis.tracker.imports.domain.DataValue;
61+
import org.hisp.dhis.tracker.imports.domain.Enrollment;
62+
import org.hisp.dhis.tracker.imports.domain.Event;
5463
import org.hisp.dhis.tracker.imports.domain.TrackerDto;
5564
import org.hisp.dhis.tracker.imports.domain.TrackerObjects;
5665
import org.hisp.dhis.tracker.imports.job.TrackerNotificationDataBundle;
5766
import org.hisp.dhis.tracker.imports.notification.NotificationHandlerService;
5867
import org.hisp.dhis.tracker.imports.preheat.TrackerPreheat;
5968
import org.hisp.dhis.tracker.imports.preheat.TrackerPreheatService;
69+
import org.hisp.dhis.tracker.imports.preheat.supplier.OptionValueSupplier;
6070
import org.hisp.dhis.tracker.imports.programrule.ProgramRuleService;
71+
import org.hisp.dhis.tracker.imports.programrule.executor.enrollment.AssignAttributeExecutor;
72+
import org.hisp.dhis.tracker.imports.programrule.executor.event.AssignDataValueExecutor;
6173
import org.hisp.dhis.tracker.imports.report.PersistenceReport;
6274
import org.hisp.dhis.tracker.imports.report.TrackerTypeReport;
6375
import org.hisp.dhis.user.UserDetails;
@@ -84,6 +96,8 @@ public class DefaultTrackerBundleService implements TrackerBundleService {
8496

8597
private final TrackerObjectDeletionService deletionService;
8698

99+
private final OptionValueSupplier optionValueSupplier;
100+
87101
private final ObjectMapper mapper;
88102

89103
private List<NotificationHandlerService> notificationHandlers = new ArrayList<>();
@@ -111,9 +125,92 @@ public TrackerBundle create(
111125
public TrackerBundle runRuleEngine(@Nonnull TrackerBundle trackerBundle) {
112126
programRuleService.calculateRuleEffects(trackerBundle, trackerBundle.getPreheat());
113127

128+
optionValueSupplier.preheatAdd(
129+
collectRuleAssignedValues(trackerBundle), trackerBundle.getPreheat());
130+
114131
return trackerBundle;
115132
}
116133

134+
/**
135+
* Collects the values {@code ASSIGN} rule actions are going to apply, shaped as a synthetic
136+
* {@link TrackerObjects} payload the {@link OptionValueSupplier} can resolve option codes from.
137+
*
138+
* <p>Rule engine validation rejects unknown option codes based on {@link
139+
* TrackerPreheat#isValidOptionCode(Long, String)}, but {@code ASSIGN} actions can add or
140+
* overwrite data values and attributes that were not in the original payload, so the codes they
141+
* introduce were never resolved during preheat and valid data would be rejected with E1125.
142+
*
143+
* <p>The values are already final here: they are the rule engine's evaluated output, captured in
144+
* the executors {@code calculateRuleEffects} just built, so nothing evaluated later can change
145+
* them.
146+
*
147+
* <p>All assigned values are gathered onto a single synthetic event and enrollment. The supplier
148+
* only looks at (data element, value) and (attribute, value) pairs, so which or how many real
149+
* entities the values belong to does not matter.
150+
*/
151+
private TrackerObjects collectRuleAssignedValues(TrackerBundle bundle) {
152+
TrackerPreheat preheat = bundle.getPreheat();
153+
154+
Set<DataValue> assignedDataValues =
155+
bundle.getEventRuleActionExecutors().values().stream()
156+
.flatMap(List::stream)
157+
.filter(AssignDataValueExecutor.class::isInstance)
158+
.map(AssignDataValueExecutor.class::cast)
159+
.map(executor -> toDataValue(preheat, executor))
160+
.filter(Objects::nonNull)
161+
.collect(Collectors.toSet());
162+
163+
List<Attribute> assignedAttributes =
164+
bundle.getEnrollmentRuleActionExecutors().values().stream()
165+
.flatMap(List::stream)
166+
.filter(AssignAttributeExecutor.class::isInstance)
167+
.map(AssignAttributeExecutor.class::cast)
168+
.map(executor -> toAttribute(preheat, executor))
169+
.filter(Objects::nonNull)
170+
.toList();
171+
172+
return TrackerObjects.builder()
173+
.events(
174+
List.of(Event.builder().event(UID.generate()).dataValues(assignedDataValues).build()))
175+
.enrollments(
176+
List.of(
177+
Enrollment.builder()
178+
.enrollment(UID.generate())
179+
.attributes(assignedAttributes)
180+
.build()))
181+
.build();
182+
}
183+
184+
/**
185+
* Executors only know the target's UID, while the supplier looks metadata up by the payload's id
186+
* scheme, so the identifier has to be converted the same way the executors themselves do when
187+
* they apply the value.
188+
*/
189+
private DataValue toDataValue(TrackerPreheat preheat, AssignDataValueExecutor executor) {
190+
DataElement dataElement = preheat.getDataElement(executor.getDataElementUid().getValue());
191+
if (dataElement == null) {
192+
return null;
193+
}
194+
195+
return DataValue.builder()
196+
.dataElement(preheat.getIdSchemes().toMetadataIdentifier(dataElement))
197+
.value(executor.getValue())
198+
.build();
199+
}
200+
201+
private Attribute toAttribute(TrackerPreheat preheat, AssignAttributeExecutor executor) {
202+
TrackedEntityAttribute attribute =
203+
preheat.getTrackedEntityAttribute(executor.getAttributeUid().getValue());
204+
if (attribute == null) {
205+
return null;
206+
}
207+
208+
return Attribute.builder()
209+
.attribute(preheat.getIdSchemes().toMetadataIdentifier(attribute))
210+
.value(executor.getValue())
211+
.build();
212+
}
213+
117214
@Nonnull
118215
@Override
119216
@Transactional

dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/imports/config/TrackerPreheatConfig.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2004-2022, University of Oslo
2+
* Copyright (c) 2004-2026, University of Oslo
33
* All rights reserved.
44
*
55
* Redistribution and use in source and binary forms, with or without
@@ -40,6 +40,7 @@
4040
import org.hisp.dhis.tracker.imports.preheat.supplier.EventProgramEnrollmentSupplier;
4141
import org.hisp.dhis.tracker.imports.preheat.supplier.EventProgramStageMapSupplier;
4242
import org.hisp.dhis.tracker.imports.preheat.supplier.FileResourceSupplier;
43+
import org.hisp.dhis.tracker.imports.preheat.supplier.OptionValueSupplier;
4344
import org.hisp.dhis.tracker.imports.preheat.supplier.OrgUnitValueTypeSupplier;
4445
import org.hisp.dhis.tracker.imports.preheat.supplier.PreheatStrategyScanner;
4546
import org.hisp.dhis.tracker.imports.preheat.supplier.PreheatSupplier;
@@ -57,6 +58,7 @@ public class TrackerPreheatConfig {
5758
private final List<Class<? extends PreheatSupplier>> preheatOrder =
5859
List.of(
5960
ClassBasedSupplier.class,
61+
OptionValueSupplier.class,
6062
DefaultsSupplier.class,
6163
TrackedEntityEnrollmentSupplier.class,
6264
EventProgramEnrollmentSupplier.class,

dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/imports/preheat/TrackerPreheat.java

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2004-2022, University of Oslo
2+
* Copyright (c) 2004-2026, University of Oslo
33
* All rights reserved.
44
*
55
* Redistribution and use in source and binary forms, with or without
@@ -137,6 +137,42 @@ private Pair<String, Set<MetadataIdentifier>> toCategoryOptionComboCacheKey(
137137
return Pair.of(categoryCombo.getUid(), categoryOptions);
138138
}
139139

140+
/**
141+
* Set of (option set id, option code) pairs confirmed to exist, populated by {@code
142+
* OptionValueSupplier}. Only pairs actually referenced by the import payload are present here —
143+
* this is not the full set of options for any given option set.
144+
*
145+
* <p>Preheated {@link org.hisp.dhis.option.OptionSet} instances deliberately carry no populated
146+
* {@code options} collection (see {@code OptionSetMapper} for why). {@link
147+
* #isValidOptionCode(Long, String)} and {@link #addValidOptionCode(Long, String)} are the
148+
* supported way to check option code validity against preheated data. Do not call {@code
149+
* OptionSet#getOptions()} on a preheated option set expecting real data — it is always empty.
150+
*/
151+
private final Set<Pair<Long, String>> validOptionCodes = new HashSet<>();
152+
153+
/**
154+
* Option sets {@code OptionValueSupplier} attempted to resolve. Lets callers tell "this code was
155+
* checked against the database and does not exist" apart from "this option set was never resolved
156+
* at all", which would be an internal bug rather than user error.
157+
*/
158+
private final Set<Long> resolvedOptionSets = new HashSet<>();
159+
160+
public void addValidOptionCode(Long optionSetId, String code) {
161+
this.validOptionCodes.add(Pair.of(optionSetId, code));
162+
}
163+
164+
public boolean isValidOptionCode(Long optionSetId, String code) {
165+
return this.validOptionCodes.contains(Pair.of(optionSetId, code));
166+
}
167+
168+
public void addResolvedOptionSet(Long optionSetId) {
169+
this.resolvedOptionSets.add(optionSetId);
170+
}
171+
172+
public boolean isOptionSetResolved(Long optionSetId) {
173+
return this.resolvedOptionSets.contains(optionSetId);
174+
}
175+
140176
/**
141177
* Check if a category option combo for given category combo and category options has been stored
142178
* using {@link #putCategoryOptionCombo}. Returns true if null and a non-null category option

dhis-2/dhis-services/dhis-service-tracker/src/main/java/org/hisp/dhis/tracker/imports/preheat/mappers/OptionSetMapper.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2004-2022, University of Oslo
2+
* Copyright (c) 2004-2026, University of Oslo
33
* All rights reserved.
44
*
55
* Redistribution and use in source and binary forms, with or without
@@ -29,15 +29,19 @@
2929
*/
3030
package org.hisp.dhis.tracker.imports.preheat.mappers;
3131

32-
import java.util.List;
33-
import org.hisp.dhis.option.Option;
3432
import org.hisp.dhis.option.OptionSet;
3533
import org.mapstruct.BeanMapping;
3634
import org.mapstruct.Mapper;
3735
import org.mapstruct.Mapping;
38-
import org.mapstruct.Named;
3936
import org.mapstruct.factory.Mappers;
4037

38+
/**
39+
* {@code options} is deliberately left unmapped — mapping it would force Hibernate to fully
40+
* materialize the {@code OptionSet.options} collection (including JSONB attribute values) on every
41+
* preheat, regardless of how many option codes the import actually references. {@link
42+
* org.hisp.dhis.tracker.imports.preheat.supplier.OptionValueSupplier} preheats only the specific
43+
* {@code (option set, code)} pairs the payload references instead.
44+
*/
4145
@Mapper(uses = DebugMapper.class)
4246
public interface OptionSetMapper extends PreheatMapper<OptionSet> {
4347
OptionSetMapper INSTANCE = Mappers.getMapper(OptionSetMapper.class);
@@ -47,9 +51,5 @@ public interface OptionSetMapper extends PreheatMapper<OptionSet> {
4751
@Mapping(target = "uid")
4852
@Mapping(target = "name")
4953
@Mapping(target = "code")
50-
@Mapping(target = "options", qualifiedByName = "options")
5154
OptionSet map(OptionSet optionSet);
52-
53-
@Named("options")
54-
List<Option> mapOptionValues(List<Option> options);
5555
}

0 commit comments

Comments
 (0)