Skip to content

Commit d3e56d0

Browse files
authored
Add more RemoteBuilder tests. Fix related to remote builder bugs (#977)
1 parent 791e367 commit d3e56d0

8 files changed

Lines changed: 749 additions & 6 deletions

File tree

server/src/main/java/com/defold/extender/remote/RemoteEngineBuilder.java

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import java.nio.file.StandardCopyOption;
5959
import java.util.Optional;
6060
import java.util.concurrent.TimeUnit;
61+
import java.util.stream.Stream;
6162
import java.util.zip.ZipEntry;
6263
import java.util.zip.ZipOutputStream;
6364
import java.nio.charset.Charset;
@@ -67,6 +68,7 @@ public class RemoteEngineBuilder {
6768

6869
private static final Logger LOGGER = LoggerFactory.getLogger(RemoteEngineBuilder.class);
6970
private static final String RECONNECT_METRIC_ID = "extender.service.remoteBuilder.reconnect";
71+
private static final int MAX_ERROR_BODY_LENGTH = 200;
7072

7173
private GCPInstanceService instanceService;
7274
private final MeterRegistry meterRegistry;
@@ -141,6 +143,12 @@ private void countReconnect(HttpHost builderHost, String operation) {
141143
"operation", operation);
142144
}
143145

146+
// Remote builders can return a large HTML error page as a job_status body; cap what we echo
147+
// into error.txt so a runaway body never bloats the file the client downloads.
148+
private static String truncate(String body) {
149+
return body.length() <= MAX_ERROR_BODY_LENGTH ? body : body.substring(0, MAX_ERROR_BODY_LENGTH) + "...";
150+
}
151+
144152
private static String requestOperation(HttpRequest request) {
145153
try {
146154
// request paths are /build_async/<platform>/<sdk>, /job_status, /job_result
@@ -207,7 +215,26 @@ public void buildAsync(final RemoteInstanceConfig remoteInstanceConfig,
207215
touchInstance(remoteInstanceConfig.getInstanceId());
208216
HttpGet statusRequest = new HttpGet(String.format("%s/job_status?jobId=%s", remoteInstanceConfig.getUrl(), jobId));
209217
try (CloseableHttpResponse statusResponse = httpClient.execute(statusRequest)) {
210-
jobStatus = Integer.valueOf(EntityUtils.toString(statusResponse.getEntity()));
218+
int statusCode = statusResponse.getStatusLine().getStatusCode();
219+
if (statusCode != HttpStatus.SC_OK) {
220+
LOGGER.error(Markers.SERVER_ERROR, "Remote builder returned HTTP {} for job_status of job {}", statusCode, jobId);
221+
File errorFile = new File(resultDir, BuilderConstants.BUILD_ERROR_FILENAME);
222+
try (PrintWriter writer = new PrintWriter(errorFile)) {
223+
writer.write(String.format("Remote builder returned HTTP %d for job_status of job %s", statusCode, jobId));
224+
}
225+
return;
226+
}
227+
String jobStatusBody = EntityUtils.toString(statusResponse.getEntity());
228+
try {
229+
jobStatus = Integer.valueOf(jobStatusBody.trim());
230+
} catch (NumberFormatException exc) {
231+
LOGGER.error(Markers.SERVER_ERROR, "Remote builder returned malformed job_status '{}' for job {}", truncate(jobStatusBody), jobId);
232+
File errorFile = new File(resultDir, BuilderConstants.BUILD_ERROR_FILENAME);
233+
try (PrintWriter writer = new PrintWriter(errorFile)) {
234+
writer.write(String.format("Remote builder returned malformed job_status '%s' for job %s", truncate(jobStatusBody), jobId));
235+
}
236+
return;
237+
}
211238
}
212239
if (jobStatus != 0) {
213240
LOGGER.info(String.format("Job %s status is %d", jobId, jobStatus));
@@ -255,6 +282,17 @@ private void downloadResult(final RemoteInstanceConfig remoteInstanceConfig, Str
255282
for (int attempt = 0; ; ++attempt) {
256283
touchInstance(remoteInstanceConfig.getInstanceId());
257284
try (CloseableHttpResponse resultResponse = httpClient.execute(new HttpGet(resultUrl))) {
285+
int resultStatusCode = resultResponse.getStatusLine().getStatusCode();
286+
if (resultStatusCode != HttpStatus.SC_OK) {
287+
// A completed HTTP error (not a mid-download IOException) is a definitive builder
288+
// fault, so don't retry it and never copy its body into build.zip.
289+
LOGGER.error(Markers.SERVER_ERROR, "Remote builder returned HTTP {} for job_result of job {}", resultStatusCode, jobId);
290+
File errorFile = new File(resultDir, BuilderConstants.BUILD_ERROR_FILENAME);
291+
try (PrintWriter writer = new PrintWriter(errorFile)) {
292+
writer.write(String.format("Remote builder returned HTTP %d for job_result of job %s", resultStatusCode, jobId));
293+
}
294+
return;
295+
}
258296
if (jobStatus == BuilderConstants.JobStatus.SUCCESS.ordinal()) {
259297
// Write zip file to result directory
260298
File tmpResult = new File(resultDir, BuilderConstants.BUILD_RESULT_FILENAME + ".tmp");
@@ -287,9 +325,11 @@ HttpEntity buildHttpEntity(final File projectDirectory, File tmpUploadArchive) t
287325
MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();
288326
entityBuilder.setStrictMode();
289327

290-
try (OutputStream fileOut = Files.newOutputStream(tmpUploadArchive.toPath()); ZipOutputStream zipStream = new ZipOutputStream(fileOut)) {
291-
Path projectDirectoryPath = projectDirectory.toPath();
292-
Files.walk(projectDirectoryPath)
328+
Path projectDirectoryPath = projectDirectory.toPath();
329+
try (OutputStream fileOut = Files.newOutputStream(tmpUploadArchive.toPath());
330+
ZipOutputStream zipStream = new ZipOutputStream(fileOut);
331+
Stream<Path> projectFiles = Files.walk(projectDirectoryPath)) {
332+
projectFiles
293333
.filter(Files::isRegularFile)
294334
.filter(path -> !path.getFileName().toString().equals(ExtenderConst.SOURCE_CODE_ARCHIVE_MAGIC_NAME))
295335
.filter(path -> !path.getFileName().toString().equals(DataCacheService.FILE_CACHE_INFO_FILE))
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.defold.extender;
2+
3+
import static org.junit.jupiter.api.Assertions.assertTrue;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
public class TimerTest {
8+
9+
@Test
10+
public void startReturnsTheElapsedTimeSinceTheLastCall() throws InterruptedException {
11+
Timer timer = new Timer();
12+
timer.start();
13+
14+
Thread.sleep(100);
15+
long lap = timer.start();
16+
17+
// Deliberately loose: Thread.sleep only guarantees a lower bound, and clock granularity
18+
// on Windows is ~15ms.
19+
assertTrue(lap >= 90, String.format("Expected a lap of at least 90ms, got %d", lap));
20+
}
21+
22+
@Test
23+
public void startResetsTheBaselineSoLapsDoNotAccumulate() throws InterruptedException {
24+
Timer timer = new Timer();
25+
timer.start();
26+
27+
Thread.sleep(100);
28+
long firstLap = timer.start();
29+
long secondLap = timer.start();
30+
31+
assertTrue(firstLap >= 90, String.format("Expected a first lap of at least 90ms, got %d", firstLap));
32+
// Without the reset inside start(), the second lap would still measure from the original
33+
// baseline and come back >= 100ms. This is the mechanism that made calling
34+
// measureRemoteEngineBuild(buildTimer.start(), ...) twice halve the reported build time.
35+
assertTrue(secondLap < 90, String.format("Expected the baseline to reset, but the second lap was %d", secondLap));
36+
}
37+
38+
@Test
39+
public void theFirstCallOnAnUnprimedTimerReturnsMillisSinceEpoch() {
40+
long before = System.currentTimeMillis();
41+
long firstLap = new Timer().start();
42+
long after = System.currentTimeMillis();
43+
44+
// The baseline starts at 0, so an unprimed Timer reports wall-clock epoch millis rather than
45+
// a duration. That is why MetricsWriter's constructor and buildAsync both prime the timer
46+
// before measuring anything.
47+
assertTrue(firstLap >= before && firstLap <= after,
48+
String.format("Expected epoch millis in [%d, %d], got %d", before, after, firstLap));
49+
}
50+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.defold.extender.metrics;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertTrue;
5+
6+
import java.util.concurrent.TimeUnit;
7+
8+
import org.junit.jupiter.api.Test;
9+
10+
import io.micrometer.core.instrument.Timer;
11+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
12+
13+
public class MetricsWriterTest {
14+
15+
private static final String REMOTE_BUILD = "extender.job.remoteBuild";
16+
private static final String PLATFORM = "x86_64-osx";
17+
18+
private static Timer remoteBuildTimer(SimpleMeterRegistry registry, String platform) {
19+
return registry.timer(REMOTE_BUILD, "platform", platform);
20+
}
21+
22+
@Test
23+
public void measureRemoteEngineBuildRecordsTheGivenDurationUnderThePlatformTag() {
24+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
25+
26+
new MetricsWriter(registry).measureRemoteEngineBuild(1234L, PLATFORM);
27+
28+
Timer timer = remoteBuildTimer(registry, PLATFORM);
29+
assertEquals(1, timer.count());
30+
assertEquals(1234.0, timer.totalTime(TimeUnit.MILLISECONDS), 0.001);
31+
}
32+
33+
@Test
34+
public void everyCallToMeasureRemoteEngineBuildRecordsOneMoreSample() {
35+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
36+
MetricsWriter writer = new MetricsWriter(registry);
37+
38+
writer.measureRemoteEngineBuild(300L, PLATFORM);
39+
writer.measureRemoteEngineBuild(100L, PLATFORM);
40+
41+
// buildAsync used to call this once at the end of the try block and again in the finally
42+
// block. Because Timer.start() resets, the second sample was ~0ms: the count doubled and the
43+
// reported mean halved. This pins the contract that RemoteEngineBuilderTest's
44+
// verify(metrics, times(1)) relies on.
45+
Timer timer = remoteBuildTimer(registry, PLATFORM);
46+
assertEquals(2, timer.count());
47+
assertEquals(400.0, timer.totalTime(TimeUnit.MILLISECONDS), 0.001);
48+
assertEquals(200.0, timer.mean(TimeUnit.MILLISECONDS), 0.001);
49+
}
50+
51+
@Test
52+
public void samplesForDifferentPlatformsAreRecordedSeparately() {
53+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
54+
MetricsWriter writer = new MetricsWriter(registry);
55+
56+
writer.measureRemoteEngineBuild(1000L, PLATFORM);
57+
writer.measureRemoteEngineBuild(2000L, "arm64-ios");
58+
59+
assertEquals(1, remoteBuildTimer(registry, PLATFORM).count());
60+
assertEquals(1000.0, remoteBuildTimer(registry, PLATFORM).totalTime(TimeUnit.MILLISECONDS), 0.001);
61+
assertEquals(1, remoteBuildTimer(registry, "arm64-ios").count());
62+
assertEquals(2000.0, remoteBuildTimer(registry, "arm64-ios").totalTime(TimeUnit.MILLISECONDS), 0.001);
63+
}
64+
65+
@Test
66+
public void theNoDurationOverloadRecordsExactlyOneSampleFromTheInternalTimer() {
67+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
68+
69+
new MetricsWriter(registry).measureRemoteEngineBuild(PLATFORM);
70+
71+
assertEquals(1, remoteBuildTimer(registry, PLATFORM).count());
72+
}
73+
74+
@Test
75+
public void theConstructorPrimesTheInternalTimerSoTheFirstSampleIsNotEpochMillis() {
76+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
77+
MetricsWriter writer = new MetricsWriter(registry);
78+
79+
writer.measureRemoteEngineBuild(PLATFORM);
80+
81+
// An unprimed com.defold.extender.Timer returns System.currentTimeMillis() on its first
82+
// start(). Were the constructor not to prime it, this would record ~1.7e12 milliseconds.
83+
double totalMillis = remoteBuildTimer(registry, PLATFORM).totalTime(TimeUnit.MILLISECONDS);
84+
assertTrue(totalMillis < 60_000,
85+
String.format("Expected a sane duration, got %f ms", totalMillis));
86+
}
87+
88+
@Test
89+
public void measureEngineBuildAndMeasureRemoteEngineBuildUseDistinctMeters() {
90+
SimpleMeterRegistry registry = new SimpleMeterRegistry();
91+
MetricsWriter writer = new MetricsWriter(registry);
92+
93+
writer.measureRemoteEngineBuild(500L, PLATFORM);
94+
95+
assertEquals(1, remoteBuildTimer(registry, PLATFORM).count());
96+
assertEquals(0, registry.timer("extender.job.build", "platform", PLATFORM).count());
97+
}
98+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.defold.extender.remote;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
import static org.junit.jupiter.api.Assertions.assertSame;
6+
import static org.junit.jupiter.api.Assertions.assertTrue;
7+
8+
import java.io.IOException;
9+
10+
import org.junit.jupiter.api.Test;
11+
12+
import com.defold.extender.ExtenderException;
13+
14+
public class RemoteBuildExceptionTest {
15+
16+
@Test
17+
public void theMessageOnlyConstructorLeavesTheCauseUnset() {
18+
RemoteBuildException exception = new RemoteBuildException("Failed to add files to multipart request");
19+
20+
assertEquals("Failed to add files to multipart request", exception.getMessage());
21+
assertNull(exception.getCause());
22+
}
23+
24+
@Test
25+
public void theCauseIsPreservedSoTheUnderlyingIoFailureStaysDiagnosable() {
26+
ExtenderException cause = new ExtenderException("Failed to create source code archive");
27+
RemoteBuildException exception = new RemoteBuildException("Failed to add files to multipart request", cause);
28+
29+
assertEquals("Failed to add files to multipart request", exception.getMessage());
30+
assertSame(cause, exception.getCause());
31+
}
32+
33+
@Test
34+
public void itIsUncheckedSoBuildAsyncCanThrowItWithoutDeclaringIt() {
35+
// buildAsync only declares FileNotFoundException and IOException. If RemoteBuildException
36+
// ever became checked, the multipart failure path would stop compiling.
37+
assertTrue(RuntimeException.class.isAssignableFrom(RemoteBuildException.class));
38+
}
39+
40+
@Test
41+
public void aCheckedCauseIsAcceptedByTheTwoArgumentConstructor() {
42+
IOException cause = new IOException("disk full");
43+
44+
assertSame(cause, new RemoteBuildException("Failed to add files to multipart request", cause).getCause());
45+
}
46+
}

0 commit comments

Comments
 (0)