Skip to content

Commit 70fced8

Browse files
committed
Programming exercises: Separate a stalled image pull from a slow one
The pull had a single wall-clock budget of 900 seconds, which is far longer than a whole build used to take and holds a build thread and an agent slot for the entire time. Lower the default to 300 seconds. A single budget also cannot tell the two failures apart. Track how many updates the daemon reports for a pull and judge it on movement as well as on total time: a large image over a slow link keeps reporting progress and may use the full budget, while a pull that reports nothing at all is abandoned after image-pull-stall-timeout-seconds, 60 by default. That is what an unreachable registry looks like from the agent when a firewall drops the packets rather than refusing the connection, and there is no reason to wait out the full budget for it. The two cases now fail with different messages, so the build log says whether the registry was slow or silent. Both close the callback, so the pull is really abandoned rather than left running in the background.
1 parent bcdda0d commit 70fced8

3 files changed

Lines changed: 150 additions & 17 deletions

File tree

src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildAgentDockerService.java

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_BUILDAGENT;
44

5+
import java.io.IOException;
56
import java.nio.file.Files;
67
import java.nio.file.Path;
78
import java.time.Duration;
@@ -16,6 +17,7 @@
1617
import java.util.Set;
1718
import java.util.concurrent.ConcurrentHashMap;
1819
import java.util.concurrent.TimeUnit;
20+
import java.util.concurrent.atomic.AtomicLong;
1921
import java.util.concurrent.locks.ReentrantLock;
2022
import java.util.stream.Collectors;
2123

@@ -88,6 +90,9 @@ public class BuildAgentDockerService {
8890

8991
private static final Logger log = LoggerFactory.getLogger(BuildAgentDockerService.class);
9092

93+
/** How often a running pull is re-examined while waiting, short enough to notice a stall promptly. */
94+
private static final int PULL_PROGRESS_POLL_INTERVAL_SECONDS = 5;
95+
9196
private final BuildAgentConfiguration buildAgentConfiguration;
9297

9398
private final DistributedDataAccessService distributedDataAccessService;
@@ -133,9 +138,20 @@ public class BuildAgentDockerService {
133138
* not on the exercise, so a slow registry must not eat into the time budget a student's build gets. Without this, a pull that never makes progress would block
134139
* the build thread indefinitely.
135140
*/
136-
@Value("${artemis.continuous-integration.image-pull-timeout-seconds:900}")
141+
@Value("${artemis.continuous-integration.image-pull-timeout-seconds:300}")
137142
private int imagePullTimeoutSeconds;
138143

144+
/**
145+
* Maximum time a Docker image pull may report no progress at all before it is aborted.
146+
* <p>
147+
* This separates the two ways a pull goes wrong. A large image over a slow link keeps emitting progress and is
148+
* allowed to run until {@link #imagePullTimeoutSeconds}. A pull that is not getting through at all, because the
149+
* registry is unreachable or a firewall silently drops the packets rather than refusing the connection, emits
150+
* nothing, and there is no reason to hold a build thread and an agent slot for the full budget waiting for it.
151+
*/
152+
@Value("${artemis.continuous-integration.image-pull-stall-timeout-seconds:60}")
153+
private int imagePullStallTimeoutSeconds;
154+
139155
/**
140156
* IDs of the build jobs that are currently pulling a Docker image, with the time the pull started.
141157
* <p>
@@ -174,6 +190,12 @@ public void applicationReady() {
174190
log.error(errorMessage);
175191
throw new IllegalArgumentException(errorMessage);
176192
}
193+
if (imagePullStallTimeoutSeconds <= 0) {
194+
String errorMessage = "The Docker image pull stall timeout must be a positive number of seconds, but was " + imagePullStallTimeoutSeconds
195+
+ ". It should be changed in the application properties under 'artemis.continuous-integration.image-pull-stall-timeout-seconds'.";
196+
log.error(errorMessage);
197+
throw new IllegalArgumentException(errorMessage);
198+
}
177199

178200
// Schedule the cleanup of dangling build containers once 10 seconds after the application has started and then every containerCleanupScheduleMinutes minutes
179201
taskScheduler.scheduleAtFixedRate(this::cleanUpContainers, Instant.now().plusSeconds(10), Duration.ofMinutes(containerCleanupScheduleMinutes));
@@ -260,8 +282,27 @@ public void cleanUpContainers() {
260282
*/
261283
public static class MyPullImageResultCallback extends PullImageResultCallback {
262284

285+
/**
286+
* How many updates the daemon has reported for this pull. Written from the docker-java callback thread and
287+
* read by the waiting build thread, hence atomic.
288+
* <p>
289+
* A counter rather than a timestamp: the caller owns the clock, so "no progress yet" is measured from when
290+
* the wait started rather than from when this object happened to be constructed.
291+
*/
292+
private final AtomicLong progressCount = new AtomicLong();
293+
294+
/**
295+
* How many updates the daemon has reported so far.
296+
*
297+
* @return the number of progress updates received for this pull
298+
*/
299+
public long progressCount() {
300+
return progressCount.get();
301+
}
302+
263303
@Override
264304
public void onNext(PullResponseItem item) {
305+
progressCount.incrementAndGet();
265306
String msg = "~~~~~~~~~~~~~~~~~~~~ Pull image progress: " + item.getStatus() + " ~~~~~~~~~~~~~~~~~~~~";
266307
log.debug(msg);
267308
super.onNext(item);
@@ -421,14 +462,50 @@ private void doPullDockerImage(BuildJobQueueItem buildJob, BuildLogsMap buildLog
421462
* @throws InterruptedException if the current thread is interrupted while waiting
422463
* @throws LocalCIException if the pull does not finish within the configured timeout
423464
*/
424-
private void awaitPullCompletion(PullImageResultCallback callback, String imageName, BuildJobQueueItem buildJob, BuildLogsMap buildLogsMap) throws InterruptedException {
425-
if (callback.awaitCompletion(imagePullTimeoutSeconds, TimeUnit.SECONDS)) {
426-
return;
465+
private void awaitPullCompletion(MyPullImageResultCallback callback, String imageName, BuildJobQueueItem buildJob, BuildLogsMap buildLogsMap) throws InterruptedException {
466+
final long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(imagePullTimeoutSeconds);
467+
final long stallNanos = TimeUnit.SECONDS.toNanos(imagePullStallTimeoutSeconds);
468+
long lastProgressAtNanos = System.nanoTime();
469+
long lastProgressCount = callback.progressCount();
470+
471+
// Wait in slices rather than one long wait, so the pull can also be judged on whether it is still moving.
472+
while (!callback.awaitCompletion(PULL_PROGRESS_POLL_INTERVAL_SECONDS, TimeUnit.SECONDS)) {
473+
long progressCount = callback.progressCount();
474+
if (progressCount != lastProgressCount) {
475+
lastProgressCount = progressCount;
476+
lastProgressAtNanos = System.nanoTime();
477+
}
478+
if (System.nanoTime() - lastProgressAtNanos > stallNanos) {
479+
abortPull(callback, imageName, buildJob, buildLogsMap,
480+
"reported no progress for " + imagePullStallTimeoutSeconds + " seconds. The registry is most likely unreachable from this agent, for example "
481+
+ "because a firewall drops the packets instead of refusing the connection");
482+
}
483+
if (System.nanoTime() - deadlineNanos >= 0) {
484+
abortPull(callback, imageName, buildJob, buildLogsMap, "did not finish within " + imagePullTimeoutSeconds + " seconds");
485+
}
486+
}
487+
}
488+
489+
/**
490+
* Closes the callback so the pull is really abandoned rather than left running in the background, then fails the build job.
491+
*
492+
* @param callback the callback of the running pull command
493+
* @param imageName the name of the Docker image being pulled
494+
* @param buildJob the build job the pull belongs to
495+
* @param buildLogsMap a map for appending log entries related to the build process
496+
* @param reason what went wrong, phrased to continue "Pulling docker image <name> ..."
497+
*/
498+
private static void abortPull(MyPullImageResultCallback callback, String imageName, BuildJobQueueItem buildJob, BuildLogsMap buildLogsMap, String reason) {
499+
try {
500+
callback.close();
501+
}
502+
catch (IOException e) {
503+
log.warn("Could not close the callback of the aborted pull of docker image {}", imageName, e);
427504
}
428-
String msg = "~~~~~~~~~~~~~~~~~~~~ Pulling docker image " + imageName + " timed out after " + imagePullTimeoutSeconds + " seconds ~~~~~~~~~~~~~~~~~~~~";
505+
String msg = "~~~~~~~~~~~~~~~~~~~~ Pulling docker image " + imageName + " " + reason + " ~~~~~~~~~~~~~~~~~~~~";
429506
log.error(msg);
430507
buildLogsMap.appendBuildLogEntry(buildJob.id(), msg);
431-
throw new LocalCIException("Timed out after " + imagePullTimeoutSeconds + " seconds while pulling docker image " + imageName);
508+
throw new LocalCIException("Pulling docker image " + imageName + " " + reason);
432509
}
433510

434511
/**

src/main/resources/config/application-buildagent.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ artemis:
7171
no-proxy: localhost,127.0.0.1
7272
# The maximum number of seconds a single Docker image pull may take before it is aborted. This is independent of the build timeout below: how long a pull takes
7373
# depends on the image size and on the registry and network, not on the exercise. Without it, a pull that stops making progress blocks the build thread forever.
74-
image-pull-timeout-seconds: 900
74+
image-pull-timeout-seconds: 300
75+
# Abort much earlier when the pull is not moving at all, which is what an unreachable registry looks like.
76+
image-pull-stall-timeout-seconds: 60
7577
# Configuration for the cleanup of Docker images
7678
image-cleanup:
7779
enabled: false

src/test/java/de/tum/cit/aet/artemis/buildagent/service/BuildAgentDockerServiceTest.java

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import static org.mockito.ArgumentMatchers.anyInt;
77
import static org.mockito.ArgumentMatchers.anyLong;
88
import static org.mockito.ArgumentMatchers.anyString;
9+
import static org.mockito.Mockito.atLeastOnce;
910
import static org.mockito.Mockito.doReturn;
1011
import static org.mockito.Mockito.doThrow;
1112
import static org.mockito.Mockito.mock;
@@ -14,11 +15,13 @@
1415
import static org.mockito.Mockito.verify;
1516
import static org.mockito.Mockito.when;
1617

18+
import java.io.IOException;
1719
import java.time.Instant;
1820
import java.time.ZonedDateTime;
1921
import java.util.List;
2022
import java.util.concurrent.TimeUnit;
2123
import java.util.concurrent.atomic.AtomicBoolean;
24+
import java.util.concurrent.atomic.AtomicLong;
2225

2326
import org.junit.jupiter.api.MethodOrderer;
2427
import org.junit.jupiter.api.Order;
@@ -119,39 +122,90 @@ void testPullDockerImage() {
119122
BuildConfig buildConfig = new BuildConfig("echo 'test'", "test-image-name", "test", "test", "test", "test", null, null, false, false, null, 0, null, null, null, null);
120123
BuildAgentDTO buildAgent = new BuildAgentDTO("buildagent1", "address1", "buildagent1");
121124
var build = new BuildJobQueueItem("1", "job1", buildAgent, 1, 1, 1, 1, 1, BuildStatus.SUCCESSFUL, null, null, buildConfig, null);
125+
// This test only cares that a missing image drives the code into a pull. Bound the wait so it cannot depend on
126+
// whatever the shared dockerClient mock still carries from another test: without a completing pull the service
127+
// would now correctly wait out the stall budget before giving up.
128+
int originalStallTimeout = (int) ReflectionTestUtils.getField(buildAgentDockerService, "imagePullStallTimeoutSeconds");
129+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullStallTimeoutSeconds", 1);
122130
// Pull image
123131
try {
124132
buildAgentDockerService.pullDockerImage(build, new BuildLogsMap());
125133
}
126134
catch (LocalCIException e) {
127-
// Expected exception
128-
if (!(e.getCause() instanceof NotFoundException)) {
129-
throw e;
130-
}
135+
// Expected: the image is missing, so either the pull itself reports the failure or it never gets through.
136+
}
137+
finally {
138+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullStallTimeoutSeconds", originalStallTimeout);
131139
}
132140

133-
// Verify that pullImageCmd() was called.
134-
verify(dockerClient, times(1)).pullImageCmd("test-image-name");
141+
// Verify that a pull was attempted. The count is deliberately not pinned: the service retries a failed pull,
142+
// so how often it gets here depends on how the pull fails rather than on what this test is about.
143+
verify(dockerClient, atLeastOnce()).pullImageCmd("test-image-name");
135144
}
136145

137146
@Test
138-
void testPullDockerImageFailsWhenPullExceedsTimeout() throws InterruptedException {
147+
void testPullDockerImageFailsFastWhenPullMakesNoProgress() throws InterruptedException, IOException {
139148
var build = mockPendingImagePull();
140-
// Simulate a pull that never finishes: the timed await reports that the image did not arrive within the timeout.
149+
// A pull that is not getting through at all: the await never completes and the daemon reports nothing, so the
150+
// progress counter never moves.
141151
when(pullImageCallback.awaitCompletion(anyLong(), any(TimeUnit.class))).thenReturn(false);
152+
when(pullImageCallback.progressCount()).thenReturn(0L);
153+
int originalStallTimeout = (int) ReflectionTestUtils.getField(buildAgentDockerService, "imagePullStallTimeoutSeconds");
154+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullStallTimeoutSeconds", 1);
142155

143156
try {
144-
assertThatThrownBy(() -> buildAgentDockerService.pullDockerImage(build, buildLogsMap)).isInstanceOf(LocalCIException.class);
157+
// This must not wait for the full pull budget: an unreachable registry is recognisable from the silence alone.
158+
assertThatThrownBy(() -> buildAgentDockerService.pullDockerImage(build, buildLogsMap)).isInstanceOf(LocalCIException.class).rootCause()
159+
.hasMessageContaining("reported no progress");
145160

146-
assertThat(buildLogsMap.getAndTruncateBuildLogs(build.id())).anyMatch(logEntry -> logEntry.log().contains("timed out after"));
161+
assertThat(buildLogsMap.getAndTruncateBuildLogs(build.id())).anyMatch(logEntry -> logEntry.log().contains("reported no progress"));
147162
// The job must not stay registered as pulling, otherwise stale detection would never look at it again.
148163
assertThat(buildAgentDockerService.isImagePullInProgress(build.id())).isFalse();
164+
// The pull is abandoned rather than left running in the background.
165+
verify(pullImageCallback).close();
166+
}
167+
finally {
168+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullStallTimeoutSeconds", originalStallTimeout);
169+
buildLogsMap.removeBuildLogs(build.id());
170+
}
171+
}
172+
173+
@Test
174+
void testPullDockerImageFailsWhenAProgressingPullExceedsTheTotalTimeout() throws InterruptedException {
175+
var build = mockPendingImagePull();
176+
// A pull that keeps reporting progress but never arrives, so only the overall budget can stop it.
177+
when(pullImageCallback.awaitCompletion(anyLong(), any(TimeUnit.class))).thenReturn(false);
178+
AtomicLong reportedProgress = new AtomicLong();
179+
when(pullImageCallback.progressCount()).thenAnswer(invocation -> reportedProgress.incrementAndGet());
180+
int originalTimeout = (int) ReflectionTestUtils.getField(buildAgentDockerService, "imagePullTimeoutSeconds");
181+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullTimeoutSeconds", 1);
182+
183+
try {
184+
assertThatThrownBy(() -> buildAgentDockerService.pullDockerImage(build, buildLogsMap)).isInstanceOf(LocalCIException.class).rootCause()
185+
.hasMessageContaining("did not finish within");
186+
187+
assertThat(buildLogsMap.getAndTruncateBuildLogs(build.id())).anyMatch(logEntry -> logEntry.log().contains("did not finish within"));
188+
assertThat(buildAgentDockerService.isImagePullInProgress(build.id())).isFalse();
149189
}
150190
finally {
191+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullTimeoutSeconds", originalTimeout);
151192
buildLogsMap.removeBuildLogs(build.id());
152193
}
153194
}
154195

196+
@Test
197+
void testNonPositivePullStallTimeoutIsRejectedAtStartup() {
198+
int originalStallTimeout = (int) ReflectionTestUtils.getField(buildAgentDockerService, "imagePullStallTimeoutSeconds");
199+
try {
200+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullStallTimeoutSeconds", 0);
201+
assertThatThrownBy(() -> buildAgentDockerService.applicationReady()).isInstanceOf(IllegalArgumentException.class)
202+
.hasMessageContaining("image-pull-stall-timeout-seconds");
203+
}
204+
finally {
205+
ReflectionTestUtils.setField(buildAgentDockerService, "imagePullStallTimeoutSeconds", originalStallTimeout);
206+
}
207+
}
208+
155209
@Test
156210
void testImagePullIsVisibleWhileItIsRunning() throws InterruptedException {
157211
var build = mockPendingImagePull();

0 commit comments

Comments
 (0)