Skip to content

Commit 4211e04

Browse files
committed
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.
1 parent 05212fb commit 4211e04

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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.test.platform;
31+
32+
import static io.gatling.javaapi.core.CoreDsl.*;
33+
import static io.gatling.javaapi.http.HttpDsl.*;
34+
35+
import com.fasterxml.jackson.databind.ObjectMapper;
36+
import io.gatling.javaapi.core.*;
37+
import io.gatling.javaapi.http.*;
38+
import java.io.FileInputStream;
39+
import java.io.IOException;
40+
import java.net.URI;
41+
import java.net.http.HttpClient;
42+
import java.net.http.HttpRequest;
43+
import java.net.http.HttpResponse;
44+
import java.nio.charset.StandardCharsets;
45+
import java.util.Base64;
46+
import java.util.Properties;
47+
48+
/**
49+
* Performance test for scalar JSON Patch updates on {@code /api/userRoles/{uid}}.
50+
*
51+
* <p>Motivated by a production incident where {@code PATCH /api/userRoles/{uid}} on a role with
52+
* many members hydrated the entire lazy {@code UserRole.members} collection during {@code
53+
* JsonPatchManager.apply}, making a one-column update cost O(members) SQL and hundreds of MB of
54+
* allocation. A scalar PATCH must be independent of role membership size; this simulation makes
55+
* that regression visible by patching both an empty control role and a large-membership role.
56+
*
57+
* <p>Scenarios (sequential, single virtual user):
58+
*
59+
* <ol>
60+
* <li><b>PATCH empty role</b>: scalar description patch on a role created in {@code before()}
61+
* with zero members (control; establishes the membership-independent baseline)
62+
* <li><b>PATCH large role</b>: the same scalar patch on a pre-existing role with large membership
63+
* ({@code userRoleUid})
64+
* <li><b>GET large role</b>: narrow-fields read of the large role (control; verifies reads stay
65+
* cheap and the role is intact)
66+
* </ol>
67+
*
68+
* <p>Available properties (with platform-perf DB defaults), settable via {@code -D} flags or a
69+
* {@code -DconfigFile=<path>.properties}:
70+
*
71+
* <ul>
72+
* <li>{@code baseUrl} (default: {@code http://localhost:8080})
73+
* <li>{@code username} (default: {@code admin})
74+
* <li>{@code password} (default: {@code district})
75+
* <li>{@code userRoleUid} (default: {@code MoRvPzDH7lc}, a role with ~83k users on the
76+
* platform-perf DB)
77+
* <li>{@code iterations} (default: {@code 10})
78+
* </ul>
79+
*
80+
* <p>No p95/max threshold assertions yet, only 100% success. Thresholds should be calibrated from
81+
* nightly baselines once this simulation has history (see {@link UsersPerformanceTest} for the
82+
* calibration workflow). On an unfixed server, the large-role PATCH may exceed Gatling's default
83+
* 60s request timeout; raise it with {@code -Dgatling.http.requestTimeout=600000}.
84+
*
85+
* @author Morten Svanæs
86+
*/
87+
public class UserRolesPerformanceTest extends Simulation {
88+
89+
private static final Properties CONFIG = loadConfig();
90+
91+
private static Properties loadConfig() {
92+
String path = System.getProperty("configFile");
93+
Properties props = new Properties();
94+
if (path != null) {
95+
try (FileInputStream fis = new FileInputStream(path)) {
96+
props.load(fis);
97+
System.out.println("[UserRolesPerformanceTest] Loaded config from: " + path);
98+
} catch (IOException e) {
99+
System.err.println(
100+
"[UserRolesPerformanceTest] Warning: could not load configFile="
101+
+ path
102+
+ ": "
103+
+ e.getMessage());
104+
}
105+
}
106+
return props;
107+
}
108+
109+
private static String prop(String key, String defaultValue) {
110+
String sys = System.getProperty(key);
111+
if (sys != null) return sys;
112+
String file = CONFIG.getProperty(key);
113+
return file != null ? file : defaultValue;
114+
}
115+
116+
private static final String BASE_URL = prop("baseUrl", "http://localhost:8080");
117+
private static final String USERNAME = prop("username", "admin");
118+
private static final String PASSWORD = prop("password", "district");
119+
private static final String BASIC_AUTH =
120+
Base64.getEncoder()
121+
.encodeToString((USERNAME + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8));
122+
private static final String LARGE_ROLE_UID = prop("userRoleUid", "MoRvPzDH7lc");
123+
private static final int ITERATIONS = Integer.parseInt(prop("iterations", "10"));
124+
125+
private static final String PATCH_EMPTY_REQUEST = "PATCH UserRole - scalar (empty role)";
126+
private static final String PATCH_LARGE_REQUEST = "PATCH UserRole - scalar (large role)";
127+
private static final String GET_LARGE_REQUEST = "GET UserRole - narrow fields (large role)";
128+
129+
private static final String PATCH_BODY_TEMPLATE =
130+
"""
131+
[{"op":"replace","path":"/description","value":"perf-patched %s"}]\
132+
""";
133+
134+
/** UID of the empty control role created in {@link #before()}. */
135+
private static volatile String emptyRoleUid;
136+
137+
/**
138+
* Creates the zero-member control role and verifies the large role exists. Fails fast if either
139+
* precondition cannot be met, so a misconfigured {@code userRoleUid} does not produce a
140+
* misleadingly green run.
141+
*/
142+
@Override
143+
public void before() {
144+
HttpClient client = HttpClient.newHttpClient();
145+
ObjectMapper mapper = new ObjectMapper();
146+
try {
147+
String name = "PerfTest empty role " + System.currentTimeMillis();
148+
HttpRequest create =
149+
HttpRequest.newBuilder()
150+
.uri(URI.create(BASE_URL + "/api/userRoles"))
151+
.header("Content-Type", "application/json")
152+
.header("Authorization", "Basic " + BASIC_AUTH)
153+
.header("Accept", "application/json")
154+
.POST(
155+
HttpRequest.BodyPublishers.ofString(
156+
"{\"name\":\"%s\",\"description\":\"perf control role\"}".formatted(name)))
157+
.build();
158+
HttpResponse<String> createResponse =
159+
client.send(create, HttpResponse.BodyHandlers.ofString());
160+
emptyRoleUid = mapper.readTree(createResponse.body()).path("response").path("uid").asText();
161+
if (emptyRoleUid.isEmpty()) {
162+
throw new IllegalStateException(
163+
"Could not create control role (HTTP "
164+
+ createResponse.statusCode()
165+
+ "): "
166+
+ createResponse.body());
167+
}
168+
169+
HttpRequest verify =
170+
HttpRequest.newBuilder()
171+
.uri(URI.create(BASE_URL + "/api/userRoles/" + LARGE_ROLE_UID + "?fields=id,name"))
172+
.header("Authorization", "Basic " + BASIC_AUTH)
173+
.header("Accept", "application/json")
174+
.GET()
175+
.build();
176+
HttpResponse<String> verifyResponse =
177+
client.send(verify, HttpResponse.BodyHandlers.ofString());
178+
if (verifyResponse.statusCode() != 200) {
179+
throw new IllegalStateException(
180+
"Large role %s not found (HTTP %d), set -DuserRoleUid to a role with many members"
181+
.formatted(LARGE_ROLE_UID, verifyResponse.statusCode()));
182+
}
183+
System.out.println(
184+
"[UserRolesPerformanceTest] control role %s, large role %s, %d iterations"
185+
.formatted(emptyRoleUid, LARGE_ROLE_UID, ITERATIONS));
186+
} catch (IOException | InterruptedException e) {
187+
Thread.currentThread().interrupt();
188+
throw new IllegalStateException("Setup failed: " + e.getMessage(), e);
189+
}
190+
}
191+
192+
/** Deletes the control role created in {@link #before()}. */
193+
@Override
194+
public void after() {
195+
if (emptyRoleUid == null || emptyRoleUid.isEmpty()) {
196+
return;
197+
}
198+
try {
199+
HttpClient.newHttpClient()
200+
.send(
201+
HttpRequest.newBuilder()
202+
.uri(URI.create(BASE_URL + "/api/userRoles/" + emptyRoleUid))
203+
.header("Authorization", "Basic " + BASIC_AUTH)
204+
.DELETE()
205+
.build(),
206+
HttpResponse.BodyHandlers.discarding());
207+
} catch (IOException | InterruptedException e) {
208+
Thread.currentThread().interrupt();
209+
System.err.println("Cleanup of control role failed: " + e.getMessage());
210+
}
211+
}
212+
213+
public UserRolesPerformanceTest() {
214+
// Same session strategy as UsersPerformanceTest: authenticate once per virtual user via a
215+
// separately-named request so the one-time bcrypt cost stays out of the measured requests.
216+
HttpProtocolBuilder httpProtocol =
217+
http.baseUrl(BASE_URL).acceptHeader("application/json").disableCaching();
218+
219+
ChainBuilder authenticate =
220+
exec(flushCookieJar())
221+
.exec(
222+
http("Authenticate (session login)")
223+
.get("/api/me")
224+
.header("Authorization", "Basic " + BASIC_AUTH)
225+
.check(status().is(200)));
226+
227+
ScenarioBuilder patchEmptyScenario =
228+
scenario(PATCH_EMPTY_REQUEST)
229+
.exec(authenticate)
230+
.repeat(ITERATIONS)
231+
.on(
232+
exec(
233+
http(PATCH_EMPTY_REQUEST)
234+
.patch(session -> "/api/userRoles/" + emptyRoleUid)
235+
.header("Content-Type", "application/json-patch+json")
236+
.body(
237+
StringBody(session -> PATCH_BODY_TEMPLATE.formatted(System.nanoTime())))
238+
.check(status().is(200))));
239+
240+
ScenarioBuilder patchLargeScenario =
241+
scenario(PATCH_LARGE_REQUEST)
242+
.exec(authenticate)
243+
.repeat(ITERATIONS)
244+
.on(
245+
exec(
246+
http(PATCH_LARGE_REQUEST)
247+
.patch("/api/userRoles/" + LARGE_ROLE_UID)
248+
.header("Content-Type", "application/json-patch+json")
249+
.body(
250+
StringBody(session -> PATCH_BODY_TEMPLATE.formatted(System.nanoTime())))
251+
.check(status().is(200))));
252+
253+
ScenarioBuilder getLargeScenario =
254+
scenario(GET_LARGE_REQUEST)
255+
.exec(authenticate)
256+
.repeat(ITERATIONS)
257+
.on(
258+
exec(
259+
http(GET_LARGE_REQUEST)
260+
.get("/api/userRoles/" + LARGE_ROLE_UID)
261+
.queryParam("fields", "id,name,description,authorities")
262+
.check(status().is(200))));
263+
264+
ClosedInjectionStep singleUser = rampConcurrentUsers(0).to(1).during(1);
265+
266+
setUp(
267+
patchEmptyScenario
268+
.injectClosed(singleUser)
269+
.andThen(patchLargeScenario.injectClosed(singleUser))
270+
.andThen(getLargeScenario.injectClosed(singleUser)))
271+
.protocols(httpProtocol)
272+
.assertions(
273+
details(PATCH_EMPTY_REQUEST).successfulRequests().percent().is(100D),
274+
details(PATCH_LARGE_REQUEST).successfulRequests().percent().is(100D),
275+
details(GET_LARGE_REQUEST).successfulRequests().percent().is(100D));
276+
}
277+
}

0 commit comments

Comments
 (0)