|
2 | 2 |
|
3 | 3 | import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_BUILDAGENT; |
4 | 4 |
|
| 5 | +import java.io.IOException; |
5 | 6 | import java.nio.file.Files; |
6 | 7 | import java.nio.file.Path; |
7 | 8 | import java.time.Duration; |
|
16 | 17 | import java.util.Set; |
17 | 18 | import java.util.concurrent.ConcurrentHashMap; |
18 | 19 | import java.util.concurrent.TimeUnit; |
| 20 | +import java.util.concurrent.atomic.AtomicLong; |
19 | 21 | import java.util.concurrent.locks.ReentrantLock; |
20 | 22 | import java.util.stream.Collectors; |
21 | 23 |
|
@@ -88,6 +90,9 @@ public class BuildAgentDockerService { |
88 | 90 |
|
89 | 91 | private static final Logger log = LoggerFactory.getLogger(BuildAgentDockerService.class); |
90 | 92 |
|
| 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 | + |
91 | 96 | private final BuildAgentConfiguration buildAgentConfiguration; |
92 | 97 |
|
93 | 98 | private final DistributedDataAccessService distributedDataAccessService; |
@@ -133,9 +138,20 @@ public class BuildAgentDockerService { |
133 | 138 | * 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 |
134 | 139 | * the build thread indefinitely. |
135 | 140 | */ |
136 | | - @Value("${artemis.continuous-integration.image-pull-timeout-seconds:900}") |
| 141 | + @Value("${artemis.continuous-integration.image-pull-timeout-seconds:300}") |
137 | 142 | private int imagePullTimeoutSeconds; |
138 | 143 |
|
| 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 | + |
139 | 155 | /** |
140 | 156 | * IDs of the build jobs that are currently pulling a Docker image, with the time the pull started. |
141 | 157 | * <p> |
@@ -174,6 +190,12 @@ public void applicationReady() { |
174 | 190 | log.error(errorMessage); |
175 | 191 | throw new IllegalArgumentException(errorMessage); |
176 | 192 | } |
| 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 | + } |
177 | 199 |
|
178 | 200 | // Schedule the cleanup of dangling build containers once 10 seconds after the application has started and then every containerCleanupScheduleMinutes minutes |
179 | 201 | taskScheduler.scheduleAtFixedRate(this::cleanUpContainers, Instant.now().plusSeconds(10), Duration.ofMinutes(containerCleanupScheduleMinutes)); |
@@ -260,8 +282,27 @@ public void cleanUpContainers() { |
260 | 282 | */ |
261 | 283 | public static class MyPullImageResultCallback extends PullImageResultCallback { |
262 | 284 |
|
| 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 | + |
263 | 303 | @Override |
264 | 304 | public void onNext(PullResponseItem item) { |
| 305 | + progressCount.incrementAndGet(); |
265 | 306 | String msg = "~~~~~~~~~~~~~~~~~~~~ Pull image progress: " + item.getStatus() + " ~~~~~~~~~~~~~~~~~~~~"; |
266 | 307 | log.debug(msg); |
267 | 308 | super.onNext(item); |
@@ -421,14 +462,50 @@ private void doPullDockerImage(BuildJobQueueItem buildJob, BuildLogsMap buildLog |
421 | 462 | * @throws InterruptedException if the current thread is interrupted while waiting |
422 | 463 | * @throws LocalCIException if the pull does not finish within the configured timeout |
423 | 464 | */ |
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); |
427 | 504 | } |
428 | | - String msg = "~~~~~~~~~~~~~~~~~~~~ Pulling docker image " + imageName + " timed out after " + imagePullTimeoutSeconds + " seconds ~~~~~~~~~~~~~~~~~~~~"; |
| 505 | + String msg = "~~~~~~~~~~~~~~~~~~~~ Pulling docker image " + imageName + " " + reason + " ~~~~~~~~~~~~~~~~~~~~"; |
429 | 506 | log.error(msg); |
430 | 507 | 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); |
432 | 509 | } |
433 | 510 |
|
434 | 511 | /** |
|
0 commit comments