diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml index 05606dc6d574..ee91803fdf91 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml @@ -348,6 +348,9 @@ + + + diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmer.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmer.java new file mode 100644 index 000000000000..6f22214e7194 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmer.java @@ -0,0 +1,146 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.core.internal.http.loader; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.reactivestreams.Publisher; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.core.internal.crac.RegionEndpointResolver; +import software.amazon.awssdk.core.internal.crac.WarmUpDiscovery; +import software.amazon.awssdk.core.internal.http.async.SimpleHttpContentPublisher; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.http.async.AsyncExecuteRequest; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.http.async.SdkAsyncHttpResponseHandler; +import software.amazon.awssdk.http.async.SdkAsyncHttpService; +import software.amazon.awssdk.http.async.SimpleSubscriber; +import software.amazon.awssdk.utils.AttributeMap; +import software.amazon.awssdk.utils.IoUtils; +import software.amazon.awssdk.utils.Logger; + +/** + * Warms every async {@link SdkAsyncHttpService} on the classpath for CRaC priming: builds each client and sends a best-effort + * {@code GET} to the resolved STS endpoint, draining the reactive response body, so the HTTP/DNS/TLS/cert-chain code is + * JIT-compiled into the snapshot. + */ +@SdkInternalApi +public final class AsyncHttpClientWarmer implements HttpClientWarmer { + + private static final Logger log = Logger.loggerFor(AsyncHttpClientWarmer.class); + + private static final long WARM_UP_TIMEOUT_SECONDS = 5; + + private final Iterable services; + private final Supplier endpointProvider; + + @SdkTestInternalApi + AsyncHttpClientWarmer(Iterable services, Supplier endpointProvider) { + this.services = services; + this.endpointProvider = endpointProvider; + } + + /** + * Warms a single {@code service} against {@code endpointProvider}. + */ + @SdkTestInternalApi + public static AsyncHttpClientWarmer forService(Supplier endpointProvider, SdkAsyncHttpService service) { + return new AsyncHttpClientWarmer(Collections.singletonList(service), endpointProvider); + } + + public static AsyncHttpClientWarmer create() { + return new AsyncHttpClientWarmer(discoverServices(), () -> RegionEndpointResolver.create().endpoint()); + } + + /** + * Like {@link #create()}, but warms against {@code endpointProvider}. + */ + @SdkTestInternalApi + public static AsyncHttpClientWarmer create(Supplier endpointProvider) { + return new AsyncHttpClientWarmer(discoverServices(), endpointProvider); + } + + private static Iterable discoverServices() { + return () -> SdkServiceLoader.INSTANCE.loadServices(SdkAsyncHttpService.class); + } + + @Override + public void warmAll() { + URI endpoint = endpointProvider.get(); + WarmUpDiscovery.forEachDiscovered(services.iterator(), service -> { + SdkAsyncHttpClient client = service.createAsyncHttpClientFactory().buildWithDefaults(AttributeMap.empty()); + warmClient(client, endpoint); + }); + } + + /** + * Sends the warm-up {@code GET} to {@code endpoint}, drains the response body, and closes the client. Best-effort: any + * failure or timeout is logged and swallowed. We block on the execute future (bounded) because the bundled async + * clients complete it only after the body is drained, so its completion implies the full path was exercised. + */ + private void warmClient(SdkAsyncHttpClient client, URI endpoint) { + try { + SdkHttpFullRequest httpRequest = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .uri(endpoint) + .build(); + AsyncExecuteRequest request = AsyncExecuteRequest.builder() + .request(httpRequest) + .requestContentPublisher(new SimpleHttpContentPublisher(httpRequest)) + .responseHandler(new WarmUpResponseHandler()) + .build(); + client.execute(request).get(WARM_UP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.debug(() -> "Async HTTP client warm-up call was interrupted (ignored).", e); + } catch (Exception e) { + // Includes ExecutionException for a failed warm-up request; best-effort, so swallow. + log.debug(() -> "Async HTTP client warm-up call failed (ignored).", e); + } finally { + IoUtils.closeQuietlyV2(client, log); + } + } + + /** + * Subscribes a {@link SimpleSubscriber} to drain and discard the response body, exercising the body-read/TLS path. + * The subscription is required: some clients complete the execute future only once the body is consumed. + */ + private static final class WarmUpResponseHandler implements SdkAsyncHttpResponseHandler { + + @Override + public void onHeaders(SdkHttpResponse headers) { + // No-op: warm-up only needs the body drained, not the headers. + } + + @Override + public void onStream(Publisher stream) { + stream.subscribe(new SimpleSubscriber(byteBuffer -> { + // Discard the bytes; warm-up only needs the path exercised. + })); + } + + @Override + public void onError(Throwable error) { + // No-op: failure is surfaced via the execute future, which the caller blocks on. + } + } +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java index 3cc15194e7c3..89610b136283 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java @@ -15,7 +15,7 @@ package software.amazon.awssdk.core.internal.http.loader; -import java.util.Collections; +import java.util.Arrays; import java.util.List; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -37,7 +37,8 @@ public final class ClasspathHttpWarmupInvoker implements HttpWarmupInvoker { * @return an invoker over the HTTP-client warmers on the classpath. */ public static HttpWarmupInvoker create() { - return new ClasspathHttpWarmupInvoker(Collections.singletonList(SyncHttpClientWarmer.create())); + return new ClasspathHttpWarmupInvoker( + Arrays.asList(SyncHttpClientWarmer.create(), AsyncHttpClientWarmer.create())); } @Override diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmerTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmerTest.java new file mode 100644 index 000000000000..9ca0343e6328 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmerTest.java @@ -0,0 +1,245 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.core.internal.http.loader; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.http.async.AsyncExecuteRequest; +import software.amazon.awssdk.http.async.SdkAsyncHttpClient; +import software.amazon.awssdk.http.async.SdkAsyncHttpResponseHandler; +import software.amazon.awssdk.http.async.SdkAsyncHttpService; +import software.amazon.awssdk.testutils.LogCaptor; + +/** + * Unit tests for {@link AsyncHttpClientWarmer}. Every test drives the real {@link AsyncHttpClientWarmer#warmAll()} with an + * injected list of stub {@link SdkAsyncHttpService}s and a fixed endpoint. Stubbed clients drive the response handler so the + * drain path runs; most settle the execute future immediately, while a couple settle it after a delay or never to cover the + * blocking and timeout paths. + */ +class AsyncHttpClientWarmerTest { + + private static final URI ENDPOINT = URI.create("https://sts.us-east-1.amazonaws.com/"); + + @Test + void warmAll_whenResponseHasBody_drainsAndClosesIt() { + AtomicBoolean drained = new AtomicBoolean(false); + Publisher body = bodyPublisher(drained, ByteBuffer.wrap("denied".getBytes())); + SdkAsyncHttpClient client = stubClient(body); + + warmer(serviceFor(client)).warmAll(); + + assertThat(drained).isTrue(); // subscribed, drained to completion + verify(client).close(); + } + + @Test + void warmAll_whenInvoked_issuesGetToResolvedEndpoint() { + SdkAsyncHttpClient client = stubClient(emptyBody()); + ArgumentCaptor request = ArgumentCaptor.forClass(AsyncExecuteRequest.class); + + warmer(serviceFor(client)).warmAll(); + + verify(client).execute(request.capture()); + assertThat(request.getValue().request().method()).isEqualTo(SdkHttpMethod.GET); + assertThat(request.getValue().request().getUri()).isEqualTo(ENDPOINT); + } + + @Test + void warmAll_whenRequestFails_swallowsAndStillClosesClient() { + SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class); + when(client.execute(any(AsyncExecuteRequest.class))).thenThrow(new RuntimeException("offline")); + + try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) { + assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException(); + + verify(client).close(); + assertThat(logCaptor.loggedEvents()) + .filteredOn(loggedFromWarmer()) + .anyMatch(event -> event.getLevel() == Level.DEBUG + && event.getMessage().getFormattedMessage().contains("warm-up call failed (ignored)")); + } + } + + @Test + void warmAll_whenNoResponseBody_stillClosesClient() { + SdkAsyncHttpClient client = stubClient(emptyBody()); + + assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException(); + verify(client).close(); + } + + @Test + void warmAll_whenExecuteTakesLongerThanTimeout_timesOutSwallowsAndClosesClient() { + // execute() returns a future that never completes, so the bounded get() times out. + SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class); + when(client.execute(any(AsyncExecuteRequest.class))).thenReturn(new CompletableFuture<>()); + + try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) { + assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException(); + + verify(client).close(); + assertThat(logCaptor.loggedEvents()) + .filteredOn(loggedFromWarmer()) + .anyMatch(event -> event.getLevel() == Level.DEBUG + && event.getMessage().getFormattedMessage().contains("warm-up call failed (ignored)")); + } + } + + @Test + void warmAll_whenStreamReadTakesLongerThanTimeout_timesOutSwallowsAndClosesClient() { + // The handler subscribes to a body that never finishes, so the execute future stays pending and get() times out. + SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class); + when(client.execute(any(AsyncExecuteRequest.class))).thenAnswer(invocation -> { + AsyncExecuteRequest request = invocation.getArgument(0); + request.responseHandler().onStream(neverEndingBody()); + return new CompletableFuture(); + }); + + try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) { + assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException(); + + verify(client).close(); + assertThat(logCaptor.loggedEvents()) + .filteredOn(loggedFromWarmer()) + .anyMatch(event -> event.getLevel() == Level.DEBUG + && event.getMessage().getFormattedMessage().contains("warm-up call failed (ignored)")); + } + } + + @Test + void warmAll_whenMultipleServicesDiscovered_warmsEach() { + SdkAsyncHttpClient first = stubClient(emptyBody()); + SdkAsyncHttpClient second = stubClient(emptyBody()); + + warmer(serviceFor(first), serviceFor(second)).warmAll(); + + verify(first).execute(any(AsyncExecuteRequest.class)); + verify(second).execute(any(AsyncExecuteRequest.class)); + } + + @Test + void warmAll_whenOneServiceFailsToBuild_stillWarmsOthers() { + SdkAsyncHttpService failing = mock(SdkAsyncHttpService.class); + when(failing.createAsyncHttpClientFactory()).thenThrow(new RuntimeException("bad service")); + SdkAsyncHttpClient healthy = stubClient(emptyBody()); + + warmer(failing, serviceFor(healthy)).warmAll(); + + verify(healthy).execute(any(AsyncExecuteRequest.class)); + } + + @Test + void warmAll_whenNoServices_isNoOp() { + assertThatCode(() -> warmer().warmAll()).doesNotThrowAnyException(); + } + + private static AsyncHttpClientWarmer warmer(SdkAsyncHttpService... services) { + return new AsyncHttpClientWarmer(Arrays.asList(services), () -> ENDPOINT); + } + + private static Predicate loggedFromWarmer() { + return event -> AsyncHttpClientWarmer.class.getName().equals(event.getLoggerName()); + } + + /** A service whose builder yields the given client. */ + private static SdkAsyncHttpService serviceFor(SdkAsyncHttpClient client) { + SdkAsyncHttpClient.Builder builder = mock(SdkAsyncHttpClient.Builder.class); + when(builder.buildWithDefaults(any())).thenReturn(client); + + SdkAsyncHttpService service = mock(SdkAsyncHttpService.class); + when(service.createAsyncHttpClientFactory()).thenReturn(builder); + return service; + } + + /** + * A client whose {@code execute} drives the response handler with the given body and completes the returned future, so + * the warmer's drain path runs and its bounded wait settles immediately. + */ + private static SdkAsyncHttpClient stubClient(Publisher body) { + SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class); + when(client.execute(any(AsyncExecuteRequest.class))).thenAnswer(invocation -> { + AsyncExecuteRequest request = invocation.getArgument(0); + SdkAsyncHttpResponseHandler handler = request.responseHandler(); + handler.onHeaders(SdkHttpResponse.builder().statusCode(403).build()); + handler.onStream(body); + return CompletableFuture.completedFuture(null); + }); + return client; + } + + /** A publisher that delivers the given chunks then completes, flipping {@code drained} once the stream completes. */ + private static Publisher bodyPublisher(AtomicBoolean drained, ByteBuffer... chunks) { + return subscriber -> subscriber.onSubscribe(new Subscription() { + private boolean done; + + @Override + public void request(long n) { + if (done) { + return; + } + done = true; + for (ByteBuffer chunk : chunks) { + subscriber.onNext(chunk); + } + drained.set(true); + subscriber.onComplete(); + } + + @Override + public void cancel() { + done = true; + } + }); + } + + private static Publisher emptyBody() { + return bodyPublisher(new AtomicBoolean(false)); + } + + /** A publisher that subscribes but never signals onNext/onComplete/onError, so the stream read never finishes. */ + private static Publisher neverEndingBody() { + return subscriber -> subscriber.onSubscribe(new Subscription() { + @Override + public void request(long n) { + // No terminal signal: the read never completes. + } + + @Override + public void cancel() { + } + }); + } +} diff --git a/test/warmup-tests/pom.xml b/test/warmup-tests/pom.xml index 1f898dd20ae4..be684f52b9c9 100644 --- a/test/warmup-tests/pom.xml +++ b/test/warmup-tests/pom.xml @@ -68,6 +68,12 @@ ${awsjavasdk.version} test + + software.amazon.awssdk + netty-nio-client + ${awsjavasdk.version} + test + software.amazon.awssdk url-connection-client diff --git a/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/AsyncHttpClientWarmUpTest.java b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/AsyncHttpClientWarmUpTest.java new file mode 100644 index 000000000000..85d32367bea7 --- /dev/null +++ b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/AsyncHttpClientWarmUpTest.java @@ -0,0 +1,110 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.http.warmup; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import java.net.URI; +import java.util.ServiceLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.internal.http.loader.AsyncHttpClientWarmer; +import software.amazon.awssdk.http.async.SdkAsyncHttpService; +import software.amazon.awssdk.http.crt.AwsCrtSdkHttpService; +import software.amazon.awssdk.http.nio.netty.NettySdkAsyncHttpService; + +/** + * Verifies the CRaC async warm-up sends its GET through each real async HTTP client. Every async client is exercised here, in + * one place, instead of a separate test in each client module. The stub mirrors a real STS {@code GET} (302 redirect, empty + * body); the warm-up must not follow the redirect, so exactly one request is expected. + */ +class AsyncHttpClientWarmUpTest { + + // Minimum async HTTP clients expected on this module's classpath: netty-nio, aws-crt async. Used as a floor so a broken + // ServiceLoader that discovers nothing fails the test instead of passing a trivial verify(0). + private static final int MIN_ASYNC_CLIENT_COUNT = 2; + + private WireMockServer mockServer; + + static SdkAsyncHttpService[] asyncClients() { + return new SdkAsyncHttpService[] { + new NettySdkAsyncHttpService(), + new AwsCrtSdkHttpService() + }; + } + + @BeforeEach + void setUp() { + mockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + mockServer.start(); + } + + @AfterEach + void tearDown() { + mockServer.stop(); + } + + @ParameterizedTest + @MethodSource("asyncClients") + void warmAll_sendsWarmUpGetThroughClient(SdkAsyncHttpService service) { + mockServer.stubFor(any(anyUrl()).willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "https://aws.amazon.com/iam"))); + + URI endpoint = URI.create("http://localhost:" + mockServer.port() + "/"); + AsyncHttpClientWarmer.forService(() -> endpoint, service).warmAll(); + + mockServer.verify(1, getRequestedFor(urlPathEqualTo("/"))); + mockServer.verify(1, anyRequestedFor(anyUrl())); + } + + /** + * Exercises the real classpath-discovery path used by {@code prime()}: {@code warmAll()} discovers every async + * {@link SdkAsyncHttpService} on the classpath via {@link ServiceLoader} and warms each. Confirms discovery finds all + * {@value #MIN_ASYNC_CLIENT_COUNT} clients and that each receives exactly one warm-up GET. + */ + @Test + void warmAll_whenDiscoveringFromClasspath_warmsEveryAsyncClient() { + mockServer.stubFor(any(anyUrl()).willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "https://aws.amazon.com/iam"))); + + int discoveredClients = 0; + for (SdkAsyncHttpService ignored : ServiceLoader.load(SdkAsyncHttpService.class)) { + discoveredClients++; + } + // Fail if discovery finds fewer clients than expected (e.g. a broken ServiceLoader finding none). + assertThat(discoveredClients).isGreaterThanOrEqualTo(MIN_ASYNC_CLIENT_COUNT); + + URI endpoint = URI.create("http://localhost:" + mockServer.port() + "/"); + AsyncHttpClientWarmer.create(() -> endpoint).warmAll(); + + // Verify against the actual discovered count, not the constant, so this stays correct as clients are added. + mockServer.verify(discoveredClients, getRequestedFor(urlPathEqualTo("/"))); + mockServer.verify(discoveredClients, anyRequestedFor(anyUrl())); + } +}