Skip to content

Commit 6d0eb53

Browse files
committed
test: tracker-import concurrency ramp for the L2 cache baseline
Adds the tracker import simulation to the ramp harness: synchronous POST /api/tracker of synthetic events against an option-heavy event program (14,000-option option set on the Sierra Leone demo database), interleaved with capture-style metadata reads, in single-event and batched payload modes. This is the workload that reproduces the second level cache collapse under concurrent load and the one the numbers in dhis2#24810 were measured with. Together with the read-heavy and write-mixed ramps and the sampling sidecar already in this branch, this completes the measurement kit: runnable locally via run-simulation-sidecars.sh, or through the performance-tests workflow (the simulations and PROF_ARGS profiling need nothing outside dhis-test-performance). AI Assisted
1 parent 0b75901 commit 6d0eb53

1 file changed

Lines changed: 338 additions & 0 deletions

File tree

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
/*
2+
* Copyright (c) 2004-2026, 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.test.platform;
31+
32+
import static io.gatling.javaapi.core.CoreDsl.StringBody;
33+
import static io.gatling.javaapi.core.CoreDsl.exec;
34+
import static io.gatling.javaapi.core.CoreDsl.listFeeder;
35+
import static io.gatling.javaapi.http.HttpDsl.http;
36+
import static io.gatling.javaapi.http.HttpDsl.status;
37+
38+
import com.fasterxml.jackson.databind.JsonNode;
39+
import com.fasterxml.jackson.databind.ObjectMapper;
40+
import com.fasterxml.jackson.databind.node.ArrayNode;
41+
import com.fasterxml.jackson.databind.node.ObjectNode;
42+
import io.gatling.javaapi.core.ChainBuilder;
43+
import io.gatling.javaapi.core.FeederBuilder;
44+
import java.io.IOException;
45+
import java.net.URI;
46+
import java.net.http.HttpClient;
47+
import java.net.http.HttpRequest;
48+
import java.net.http.HttpResponse;
49+
import java.time.LocalDate;
50+
import java.util.ArrayList;
51+
import java.util.List;
52+
import java.util.Map;
53+
import java.util.concurrent.ThreadLocalRandom;
54+
55+
/**
56+
* Tracker-import ramp simulation for the L2-cache truth mission (Phase 4 primary workload).
57+
*
58+
* <p>Concurrent {@code POST /api/tracker?async=false} of synthetic events against an option-heavy
59+
* event program (default: Inpatient morbidity and mortality {@code eBAyeGv0exc}, Sierra Leone demo
60+
* DB), mixed with capture-style metadata reads. Event data values are drawn randomly from the
61+
* program's real option sets, fetched at simulation start (option-set sizes are logged so every run
62+
* records how option-heavy the workload actually is). The import path repeatedly query-loads
63+
* reference metadata (options, option sets, data elements, org units), which under READ_WRITE L2
64+
* regions takes the region write lock per hydrated row -- the convoy mechanism this workload exists
65+
* to measure.
66+
*
67+
* <p>Sync vs async: the import is measured SYNCHRONOUSLY ({@code async=false}) so request latency
68+
* attributes the full import transaction cost (validation, preheat, persistence) to the request.
69+
* Uganda's pipeline uses the async default, but async would only measure job-enqueue latency, which
70+
* is useless for region-lock attribution. The DB starts fresh per harness run (seeded volume), so
71+
* within-run growth is part of the workload and identical across matrix cells.
72+
*
73+
* <p>Additional properties on top of {@link L2CacheRampSimulation}:
74+
*
75+
* <ul>
76+
* <li>{@code trackerProgramUid} (default: {@code eBAyeGv0exc}) event program under load
77+
* <li>{@code importMode} (default: {@code single}): {@code single} = capture-style one event per
78+
* POST; {@code batch} = Uganda-style sync payloads of {@code eventsPerRequest} events
79+
* <li>{@code eventsPerRequest} (default: {@code 100}) events per POST in {@code batch} mode
80+
* </ul>
81+
*
82+
* @author Morten Svanæs
83+
*/
84+
public class L2CacheTrackerImportRampTest extends L2CacheRampSimulation {
85+
86+
private static final String PROGRAM_UID = prop("trackerProgramUid", "eBAyeGv0exc");
87+
private static final String IMPORT_MODE = prop("importMode", "single");
88+
private static final int EVENTS_PER_REQUEST = Integer.parseInt(prop("eventsPerRequest", "100"));
89+
90+
private static final ObjectMapper MAPPER = new ObjectMapper();
91+
92+
/** One data element of the program stage plus the value pool to draw from. */
93+
private record DataElementSpec(String uid, String valueType, List<String> optionCodes) {}
94+
95+
private record ProgramSpec(
96+
String programUid,
97+
String stageUid,
98+
List<String> orgUnits,
99+
List<DataElementSpec> elements,
100+
List<String> optionSetUids) {}
101+
102+
private static final ProgramSpec PROGRAM = fetchProgramSpec();
103+
104+
/** Option set UIDs of the program, fed into capture-style option-set reads. */
105+
private static final FeederBuilder<Object> OPTION_SET_FEEDER = optionSetFeeder();
106+
107+
public L2CacheTrackerImportRampTest() {
108+
install(L2CacheTrackerImportRampTest::workflow);
109+
}
110+
111+
private static ChainBuilder workflow(String p) {
112+
// Capture-style reads: the metadata the Capture app hits while users enter events. The
113+
// option-set read hammers the Option/OptionSet regions the import path also touches.
114+
ChainBuilder reads =
115+
exec(http(p + " me").get("/api/me").check(status().is(200)))
116+
.feed(OPTION_SET_FEEDER)
117+
.exec(
118+
http(p + " optionSet byId")
119+
.get("/api/optionSets/#{osUid}")
120+
.queryParam("fields", "id,name,valueType,options[id,name,code]")
121+
.check(status().is(200)))
122+
.exec(
123+
http(p + " events workingList")
124+
.get("/api/tracker/events")
125+
.queryParam("program", PROGRAM.programUid())
126+
.queryParam("orgUnit", ORG_UNIT_UID)
127+
.queryParam("orgUnitMode", "DESCENDANTS")
128+
.queryParam("order", "occurredAt:desc")
129+
.queryParam("pageSize", "25")
130+
.check(status().is(200)));
131+
132+
int eventsPerPost = "batch".equalsIgnoreCase(IMPORT_MODE) ? EVENTS_PER_REQUEST : 1;
133+
String importName =
134+
p + (eventsPerPost == 1 ? " tracker import single" : " tracker import batch");
135+
ChainBuilder importEvents =
136+
exec(
137+
http(importName)
138+
.post("/api/tracker")
139+
.queryParam("async", "false")
140+
.header("Content-Type", "application/json")
141+
.body(StringBody(session -> eventsPayload(eventsPerPost)))
142+
.check(status().is(200)));
143+
144+
return reads.exec(importEvents);
145+
}
146+
147+
// -------------------------------------------------------------------------
148+
// Synthetic event payloads
149+
// -------------------------------------------------------------------------
150+
151+
private static String eventsPayload(int eventCount) {
152+
ObjectNode root = MAPPER.createObjectNode();
153+
ArrayNode events = root.putArray("events");
154+
for (int i = 0; i < eventCount; i++) {
155+
events.add(randomEvent());
156+
}
157+
return root.toString();
158+
}
159+
160+
private static ObjectNode randomEvent() {
161+
ThreadLocalRandom random = ThreadLocalRandom.current();
162+
ObjectNode event = MAPPER.createObjectNode();
163+
event.put("program", PROGRAM.programUid());
164+
event.put("programStage", PROGRAM.stageUid());
165+
event.put("orgUnit", PROGRAM.orgUnits().get(random.nextInt(PROGRAM.orgUnits().size())));
166+
event.put("status", "ACTIVE");
167+
event.put("occurredAt", LocalDate.now().minusDays(random.nextInt(90)).toString());
168+
ArrayNode dataValues = event.putArray("dataValues");
169+
for (DataElementSpec spec : PROGRAM.elements()) {
170+
String value = randomValue(spec, random);
171+
if (value == null) {
172+
continue;
173+
}
174+
ObjectNode dataValue = dataValues.addObject();
175+
dataValue.put("dataElement", spec.uid());
176+
dataValue.put("value", value);
177+
}
178+
return event;
179+
}
180+
181+
private static String randomValue(DataElementSpec spec, ThreadLocalRandom random) {
182+
if (!spec.optionCodes().isEmpty()) {
183+
return spec.optionCodes().get(random.nextInt(spec.optionCodes().size()));
184+
}
185+
return switch (spec.valueType()) {
186+
case "INTEGER", "INTEGER_POSITIVE", "INTEGER_ZERO_OR_POSITIVE" ->
187+
String.valueOf(random.nextInt(1, 99));
188+
case "INTEGER_NEGATIVE" -> String.valueOf(-random.nextInt(1, 99));
189+
case "NUMBER", "PERCENTAGE" -> String.valueOf(random.nextInt(1, 99)) + ".5";
190+
case "UNIT_INTERVAL" -> "0." + random.nextInt(1, 9);
191+
case "BOOLEAN" -> String.valueOf(random.nextBoolean());
192+
case "TRUE_ONLY" -> "true";
193+
case "DATE" -> LocalDate.now().minusDays(random.nextInt(365)).toString();
194+
case "DATETIME" -> LocalDate.now().minusDays(random.nextInt(365)) + "T10:00:00.000";
195+
case "TIME" -> "10:30";
196+
case "PHONE_NUMBER" -> "+4712345678";
197+
case "EMAIL" -> "l2-cache-truth@example.com";
198+
case "TEXT", "LONG_TEXT" -> "l2 perf " + random.nextInt(1_000_000);
199+
// FILE_RESOURCE, IMAGE, COORDINATE, ORGANISATION_UNIT, USERNAME... are skipped
200+
default -> null;
201+
};
202+
}
203+
204+
// -------------------------------------------------------------------------
205+
// Program metadata fetched once at simulation start
206+
// -------------------------------------------------------------------------
207+
208+
private static ProgramSpec fetchProgramSpec() {
209+
String url =
210+
BASE_URL
211+
+ "/api/programs/"
212+
+ PROGRAM_UID
213+
+ ".json?fields=id,name,programType,categoryCombo[id,name],organisationUnits[id],"
214+
+ "programStages[id,name,programStageDataElements[dataElement[id,name,valueType,"
215+
+ "optionSet[id,name,options[code]]]]]";
216+
JsonNode program = getJson(url);
217+
218+
if (!"WITHOUT_REGISTRATION".equals(program.path("programType").asText())) {
219+
throw new IllegalStateException(
220+
"Program " + PROGRAM_UID + " is not an event program: " + program.path("programType"));
221+
}
222+
JsonNode stages = program.path("programStages");
223+
if (stages.size() != 1) {
224+
throw new IllegalStateException(
225+
"Expected exactly one program stage on " + PROGRAM_UID + ", found " + stages.size());
226+
}
227+
JsonNode stage = stages.get(0);
228+
229+
List<String> orgUnits = new ArrayList<>();
230+
for (JsonNode orgUnit : program.path("organisationUnits")) {
231+
orgUnits.add(orgUnit.path("id").asText());
232+
}
233+
if (orgUnits.isEmpty()) {
234+
throw new IllegalStateException("Program " + PROGRAM_UID + " has no org units assigned");
235+
}
236+
237+
List<DataElementSpec> elements = new ArrayList<>();
238+
List<String> optionSetUids = new ArrayList<>();
239+
int totalOptions = 0;
240+
for (JsonNode psde : stage.path("programStageDataElements")) {
241+
JsonNode dataElement = psde.path("dataElement");
242+
List<String> optionCodes = new ArrayList<>();
243+
JsonNode optionSet = dataElement.path("optionSet");
244+
if (!optionSet.isMissingNode()) {
245+
for (JsonNode option : optionSet.path("options")) {
246+
optionCodes.add(option.path("code").asText());
247+
}
248+
if (!optionCodes.isEmpty()) {
249+
if (!optionSetUids.contains(optionSet.path("id").asText())) {
250+
optionSetUids.add(optionSet.path("id").asText());
251+
}
252+
totalOptions += optionCodes.size();
253+
System.out.println(
254+
"[L2CacheTrackerImportRampTest] optionSet "
255+
+ optionSet.path("name").asText()
256+
+ " ("
257+
+ optionSet.path("id").asText()
258+
+ ") size="
259+
+ optionCodes.size()
260+
+ " on dataElement "
261+
+ dataElement.path("name").asText());
262+
}
263+
}
264+
elements.add(
265+
new DataElementSpec(
266+
dataElement.path("id").asText(),
267+
dataElement.path("valueType").asText(),
268+
List.copyOf(optionCodes)));
269+
}
270+
if (elements.isEmpty()) {
271+
throw new IllegalStateException("Program stage of " + PROGRAM_UID + " has no data elements");
272+
}
273+
274+
System.out.println(
275+
"[L2CacheTrackerImportRampTest] program "
276+
+ program.path("name").asText()
277+
+ " ("
278+
+ PROGRAM_UID
279+
+ "): stage "
280+
+ stage.path("id").asText()
281+
+ ", "
282+
+ elements.size()
283+
+ " data elements, "
284+
+ optionSetUids.size()
285+
+ " distinct option sets with "
286+
+ totalOptions
287+
+ " options total, "
288+
+ orgUnits.size()
289+
+ " org units, categoryCombo="
290+
+ program.path("categoryCombo").path("name").asText()
291+
+ ", importMode="
292+
+ IMPORT_MODE
293+
+ (("batch".equalsIgnoreCase(IMPORT_MODE))
294+
? " (" + EVENTS_PER_REQUEST + " events/request)"
295+
: ""));
296+
297+
return new ProgramSpec(
298+
program.path("id").asText(),
299+
stage.path("id").asText(),
300+
List.copyOf(orgUnits),
301+
List.copyOf(elements),
302+
List.copyOf(optionSetUids));
303+
}
304+
305+
private static FeederBuilder<Object> optionSetFeeder() {
306+
if (PROGRAM.optionSetUids().isEmpty()) {
307+
throw new IllegalStateException(
308+
"Program " + PROGRAM_UID + " has no option sets; the option-heavy workload needs them");
309+
}
310+
List<Map<String, Object>> records = new ArrayList<>();
311+
for (String osUid : PROGRAM.optionSetUids()) {
312+
records.add(Map.of("osUid", osUid));
313+
}
314+
return listFeeder(records).random();
315+
}
316+
317+
private static JsonNode getJson(String url) {
318+
try {
319+
HttpClient client = HttpClient.newHttpClient();
320+
HttpRequest request =
321+
HttpRequest.newBuilder()
322+
.uri(URI.create(url))
323+
.header("Authorization", "Basic " + BASIC_AUTH)
324+
.GET()
325+
.build();
326+
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
327+
if (response.statusCode() != 200) {
328+
throw new IllegalStateException(url + " failed with HTTP " + response.statusCode());
329+
}
330+
return MAPPER.readTree(response.body());
331+
} catch (IOException e) {
332+
throw new IllegalStateException("Failed to fetch " + url, e);
333+
} catch (InterruptedException e) {
334+
Thread.currentThread().interrupt();
335+
throw new IllegalStateException("Interrupted while fetching " + url, e);
336+
}
337+
}
338+
}

0 commit comments

Comments
 (0)