diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/crac/SdkWarmUp.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/crac/SdkWarmUp.java index 9bd34cab3122..00e83cd19e8d 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/crac/SdkWarmUp.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/crac/SdkWarmUp.java @@ -15,11 +15,21 @@ package software.amazon.awssdk.core.crac; +import java.util.LinkedHashSet; import java.util.ServiceLoader; +import java.util.Set; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.ThreadSafe; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.SdkClient; import software.amazon.awssdk.core.internal.crac.ClasspathWarmUpInvoker; +import software.amazon.awssdk.core.internal.crac.PrimedClientRegistry; +import software.amazon.awssdk.core.internal.crac.TargetedWarmUpInvoker; +import software.amazon.awssdk.core.internal.crac.TargetedWarmUpResult; +import software.amazon.awssdk.core.internal.http.loader.AsyncHttpClientWarmer; import software.amazon.awssdk.core.internal.http.loader.ClasspathHttpWarmupInvoker; +import software.amazon.awssdk.core.internal.http.loader.SyncHttpClientWarmer; +import software.amazon.awssdk.utils.Logger; /** * Entry point for warming up SDK service request paths before a Coordinated Restore at Checkpoint (CRaC) @@ -45,10 +55,14 @@ @SdkPublicApi public final class SdkWarmUp { + private static final Logger log = Logger.loggerFor(SdkWarmUp.class); + private static final Object PRIME_LOCK = new Object(); private static volatile boolean primed = false; + private static final PrimedClientRegistry PRIMED_CLIENTS = new PrimedClientRegistry(); + private SdkWarmUp() { } @@ -56,6 +70,9 @@ private SdkWarmUp() { * Discovers every {@link SdkWarmUpProvider} on the classpath and invokes {@link SdkWarmUpProvider#warmUp()} * on each, honoring the idempotency, per-provider resilience, and empty-classpath behavior described on * this class. Safe to call concurrently. + * + *

This method and {@link #prime(Class[])} track primed state independently: this method warms + * every provider and does not skip clients that were warmed by a targeted {@link #prime(Class[])} call. */ public static void prime() { if (primed) { @@ -71,4 +88,46 @@ public static void prime() { primed = true; } } + + /** + * Primes only the given service clients, warming the sync path for a sync client and the async path for an async + * client. A client already primed by this method is skipped; a class matched by no provider, or whose warm-up + * fails, is logged at warn and retried on the next call.Best-effort and safe to call concurrently. + * + *

This method and {@link #prime()} track primed state independently: this method does not skip + * clients that {@link #prime()} already warmed. + * + * @param clients the service client classes to prime, for example {@code ServiceClient.class} or + * {@code ServiceAsyncClient.class}. + */ + @SafeVarargs + public static void prime(Class... clients) { + if (clients == null || clients.length == 0) { + log.debug(() -> "SdkWarmUp.prime(Class...) called with no clients; nothing to do."); + return; + } + + Set requested = new LinkedHashSet<>(); + for (Class client : clients) { + if (client != null) { + requested.add(client.getName()); + } + } + Set toPrime = PRIMED_CLIENTS.selectUnprimed(requested); + if (toPrime.isEmpty()) { + return; + } + + // Racing calls may double-warm the same client; warming is idempotent, so that is harmless. + TargetedWarmUpResult result = TargetedWarmUpInvoker.create().invoke(toPrime); + if (result.matchedClientTypes().contains(ClientType.SYNC)) { + SyncHttpClientWarmer.create().warmAll(); + } + if (result.matchedClientTypes().contains(ClientType.ASYNC)) { + AsyncHttpClientWarmer.create().warmAll(); + } + + // Only successfully warmed names are recorded; unmatched or failed ones are retried on a later call. + PRIMED_CLIENTS.markPrimed(result.warmedClientNames()); + } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/PrimedClientRegistry.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/PrimedClientRegistry.java new file mode 100644 index 000000000000..77931b4f87d4 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/PrimedClientRegistry.java @@ -0,0 +1,55 @@ +/* + * 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.crac; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.ThreadSafe; + +/** + * Tracks which service clients {@code SdkWarmUp.prime(Class...)} has already warmed, so each is warmed at most once per + * JVM. State is per-instance, so it is unit-testable with fresh instances; production uses a single long-lived instance. + */ +@ThreadSafe +@SdkInternalApi +public final class PrimedClientRegistry { + + private final Set primed = ConcurrentHashMap.newKeySet(); + + /** + * Returns the not-yet-primed subset of {@code clientClassNames}, in encounter order, ignoring nulls. Warm the + * returned names, then pass them to {@link #markPrimed(Collection)}. + */ + public Set selectUnprimed(Collection clientClassNames) { + Set unprimed = new LinkedHashSet<>(); + for (String name : clientClassNames) { + if (name != null && !primed.contains(name)) { + unprimed.add(name); + } + } + return unprimed; + } + + /** + * Records the given names as primed. Call only after warming completes, so a failed run is retried by a later call. + */ + public void markPrimed(Collection clientClassNames) { + primed.addAll(clientClassNames); + } +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpInvoker.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpInvoker.java new file mode 100644 index 000000000000..d45cef6514b3 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpInvoker.java @@ -0,0 +1,106 @@ +/* + * 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.crac; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.crac.SdkWarmUpProvider; +import software.amazon.awssdk.utils.Logger; + +/** + * Warms only the service clients named by {@code SdkWarmUp.prime(Class...)}. Matches each requested client class name + * against the discovered {@link SdkWarmUpProvider}s' sync and async class names, then warms the matched client type only. + */ +@SdkInternalApi +public final class TargetedWarmUpInvoker { + + private static final Logger log = Logger.loggerFor(TargetedWarmUpInvoker.class); + + private final WarmUpServiceLoader serviceLoader; + + @SdkTestInternalApi + TargetedWarmUpInvoker(WarmUpServiceLoader serviceLoader) { + this.serviceLoader = serviceLoader; + } + + public static TargetedWarmUpInvoker create() { + return new TargetedWarmUpInvoker(WarmUpServiceLoader.INSTANCE); + } + + /** + * Warms the provider client type matching each requested client class name. An unmatched name is logged + * at warn and skipped, and a provider that fails does not stop the others. + * + * @param requestedClassNames the fully qualified sync or async client class names to warm. + * @return the matched client types and the names that warmed successfully. + */ + public TargetedWarmUpResult invoke(Collection requestedClassNames) { + Set matchedClientTypes = EnumSet.noneOf(ClientType.class); + Set warmedClientNames = new LinkedHashSet<>(); + if (requestedClassNames.isEmpty()) { + return new TargetedWarmUpResult(matchedClientTypes, warmedClientNames); + } + + List providers = loadProviders(); + for (String requested : requestedClassNames) { + boolean warmFailed = false; + Set matched = EnumSet.noneOf(ClientType.class); + for (SdkWarmUpProvider provider : providers) { + ClientType clientType = clientTypeFor(requested, provider); + if (clientType == null) { + continue; + } + matched.add(clientType); + try { + provider.warmUpClient(clientType); + } catch (RuntimeException | LinkageError e) { + warmFailed = true; + log.warn(() -> "Warm-up failed for " + provider.getClass().getName() + " and was skipped.", e); + } + } + if (matched.isEmpty()) { + log.warn(() -> "No warm-up provider matched client class " + requested + "; skipping."); + } else if (!warmFailed) { + warmedClientNames.add(requested); + } + matchedClientTypes.addAll(matched); + } + return new TargetedWarmUpResult(matchedClientTypes, warmedClientNames); + } + + private ClientType clientTypeFor(String requested, SdkWarmUpProvider provider) { + if (requested.equals(provider.syncClientClassName())) { + return ClientType.SYNC; + } + if (requested.equals(provider.asyncClientClassName())) { + return ClientType.ASYNC; + } + return null; + } + + private List loadProviders() { + List providers = new ArrayList<>(); + WarmUpDiscovery.forEachDiscovered(serviceLoader.loadProviders(), providers::add); + return providers; + } +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpResult.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpResult.java new file mode 100644 index 000000000000..1ca19c0e1712 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpResult.java @@ -0,0 +1,78 @@ +/* + * 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.crac; + +import java.util.Collections; +import java.util.Set; +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.utils.ToString; + +/** + * Outcome of a {@link TargetedWarmUpInvoker} run: the client types that matched and the client names that warmed + * successfully. + */ +@SdkInternalApi +public final class TargetedWarmUpResult { + + private final Set matchedClientTypes; + private final Set warmedClientNames; + + TargetedWarmUpResult(Set matchedClientTypes, Set warmedClientNames) { + this.matchedClientTypes = Collections.unmodifiableSet(matchedClientTypes); + this.warmedClientNames = Collections.unmodifiableSet(warmedClientNames); + } + + public Set matchedClientTypes() { + return matchedClientTypes; + } + + public Set warmedClientNames() { + return warmedClientNames; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + TargetedWarmUpResult that = (TargetedWarmUpResult) o; + + if (!matchedClientTypes.equals(that.matchedClientTypes)) { + return false; + } + return warmedClientNames.equals(that.warmedClientNames); + } + + @Override + public int hashCode() { + int result = matchedClientTypes.hashCode(); + result = 31 * result + warmedClientNames.hashCode(); + return result; + } + + @Override + public String toString() { + return ToString.builder("TargetedWarmUpResult") + .add("matchedClientTypes", matchedClientTypes) + .add("warmedClientNames", warmedClientNames) + .build(); + } +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredAsyncClient.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredAsyncClient.java new file mode 100644 index 000000000000..77e92477d628 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredAsyncClient.java @@ -0,0 +1,24 @@ +/* + * 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.crac; + +import software.amazon.awssdk.core.SdkClient; + +/** + * Test-only client whose fully qualified name matches {@link RegisteredWarmUpProvider#asyncClientClassName()}. + */ +public interface RegisteredAsyncClient extends SdkClient { +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredSyncClient.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredSyncClient.java new file mode 100644 index 000000000000..f84155ecf9ec --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredSyncClient.java @@ -0,0 +1,25 @@ +/* + * 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.crac; + +import software.amazon.awssdk.core.SdkClient; + +/** + * Test-only client whose fully qualified name matches {@link RegisteredWarmUpProvider#syncClientClassName()}, so + * {@code SdkWarmUp.prime(RegisteredSyncClient.class)} matches that provider's sync client type. + */ +public interface RegisteredSyncClient extends SdkClient { +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredWarmUpProvider.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredWarmUpProvider.java index 726e83b9812f..c3e94968556f 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredWarmUpProvider.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/RegisteredWarmUpProvider.java @@ -15,18 +15,27 @@ package software.amazon.awssdk.core.crac; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import software.amazon.awssdk.core.ClientType; /** * Test-only {@link SdkWarmUpProvider} registered in test-scoped {@code META-INF/services} so a real {@link - * java.util.ServiceLoader} discovers and instantiates it by name. {@code INVOCATIONS} is static because the loader + * java.util.ServiceLoader} discovers and instantiates it by name. Both static fields are static because the loader * builds its own instance. Must be public with a no-arg constructor for ServiceLoader. + * + *

{@code INVOCATIONS} counts full {@link #warmUp()} calls; {@code WARMED_CLIENTS} records the client types passed to + * {@link #warmUpClient(ClientType)}, so a test can tell the targeted path from the full one. */ public final class RegisteredWarmUpProvider implements SdkWarmUpProvider { public static final AtomicInteger INVOCATIONS = new AtomicInteger(); + public static final Set WARMED_CLIENTS = + Collections.synchronizedSet(EnumSet.noneOf(ClientType.class)); + @Override public void warmUp() { INVOCATIONS.incrementAndGet(); @@ -44,5 +53,6 @@ public String asyncClientClassName() { @Override public void warmUpClient(ClientType clientType) { + WARMED_CLIENTS.add(clientType); } } diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/SdkWarmUpTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/SdkWarmUpTest.java index e21cdf3dd657..53bc6880ed6c 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/SdkWarmUpTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/crac/SdkWarmUpTest.java @@ -16,13 +16,18 @@ package software.amazon.awssdk.core.crac; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; +import org.apache.logging.log4j.Level; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.SdkClient; +import software.amazon.awssdk.testutils.LogCaptor; /** * Tests the static {@link SdkWarmUp#prime()} entry point end to end through {@link java.util.ServiceLoader}, @@ -38,6 +43,8 @@ void setup() { // Dummy region so prime()'s HTTP warm-up resolves a non-existent STS host and fails DNS immediately, keeping the test offline. savedRegionProperty = System.getProperty("aws.region"); System.setProperty("aws.region", "warmup-unit-test"); + RegisteredWarmUpProvider.INVOCATIONS.set(0); + RegisteredWarmUpProvider.WARMED_CLIENTS.clear(); } @AfterEach @@ -51,7 +58,6 @@ void teardown() { @Test void prime_concurrentCalls_invokeRegisteredProviderExactlyOnce() throws InterruptedException { - RegisteredWarmUpProvider.INVOCATIONS.set(0); int threadCount = 16; CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); @@ -80,4 +86,53 @@ void prime_concurrentCalls_invokeRegisteredProviderExactlyOnce() throws Interrup assertThat(RegisteredWarmUpProvider.INVOCATIONS.get()).isEqualTo(1); } + + @Test + void prime_withMatchingSyncClient_warmsSyncClientTypeThroughWarmUpClient() { + SdkWarmUp.prime(RegisteredSyncClient.class); + + // Targeted prime warms via warmUpClient() only; the full warmUp() path (counted by INVOCATIONS) must not run. + assertThat(RegisteredWarmUpProvider.INVOCATIONS.get()).isEqualTo(0); + assertThat(RegisteredWarmUpProvider.WARMED_CLIENTS).containsExactly(ClientType.SYNC); + } + + @Test + void prime_withMatchingAsyncClient_warmsAsyncClientTypeThroughWarmUpClient() { + SdkWarmUp.prime(RegisteredAsyncClient.class); + + assertThat(RegisteredWarmUpProvider.INVOCATIONS.get()).isEqualTo(0); + assertThat(RegisteredWarmUpProvider.WARMED_CLIENTS).containsExactly(ClientType.ASYNC); + } + + @Test + void prime_withUnmatchedClient_doesNotThrow() { + assertThatCode(() -> SdkWarmUp.prime(UnmatchedClient.class)).doesNotThrowAnyException(); + } + + @Test + void prime_withUnmatchedClient_warnsOnEveryCallAndIsRetried() { + // An unmatched client is not recorded as primed, so every call warns and retries. + try (LogCaptor logCaptor = LogCaptor.create(Level.WARN)) { + SdkWarmUp.prime(RetriedUnmatchedClient.class); + SdkWarmUp.prime(RetriedUnmatchedClient.class); + + long unmatchedWarns = logCaptor.loggedEvents().stream() + .filter(event -> event.getLevel() == Level.WARN + && event.getMessage().getFormattedMessage() + .contains(RetriedUnmatchedClient.class.getName())) + .count(); + assertThat(unmatchedWarns).isEqualTo(2); + } + } + + @Test + void prime_withEmptyArray_isNoOp() { + assertThatCode(() -> SdkWarmUp.prime(new Class[0])).doesNotThrowAnyException(); + } + + interface UnmatchedClient extends SdkClient { + } + + interface RetriedUnmatchedClient extends SdkClient { + } } diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/PrimedClientRegistryTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/PrimedClientRegistryTest.java new file mode 100644 index 000000000000..0c3170800cec --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/PrimedClientRegistryTest.java @@ -0,0 +1,91 @@ +/* + * 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.crac; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PrimedClientRegistry}. Each test uses a fresh instance, so the dedup state is isolated without + * any static reset hook or reflection. + */ +class PrimedClientRegistryTest { + + private static final String SERVICE1_SYNC = "software.amazon.awssdk.services.service1.Service1Client"; + private static final String SERVICE1_ASYNC = "software.amazon.awssdk.services.service1.Service1AsyncClient"; + + @Test + void selectUnprimed_freshRegistry_returnsAllRequested() { + PrimedClientRegistry registry = new PrimedClientRegistry(); + + assertThat(registry.selectUnprimed(Arrays.asList(SERVICE1_SYNC, SERVICE1_ASYNC))) + .containsExactly(SERVICE1_SYNC, SERVICE1_ASYNC); + } + + @Test + void selectUnprimed_afterMarkPrimed_skipsAlreadyPrimed() { + PrimedClientRegistry registry = new PrimedClientRegistry(); + registry.markPrimed(Collections.singletonList(SERVICE1_SYNC)); + + assertThat(registry.selectUnprimed(Arrays.asList(SERVICE1_SYNC, SERVICE1_ASYNC))) + .containsExactly(SERVICE1_ASYNC); + } + + @Test + void selectUnprimed_allAlreadyPrimed_returnsEmpty() { + PrimedClientRegistry registry = new PrimedClientRegistry(); + registry.markPrimed(Arrays.asList(SERVICE1_SYNC, SERVICE1_ASYNC)); + + assertThat(registry.selectUnprimed(Arrays.asList(SERVICE1_SYNC, SERVICE1_ASYNC))).isEmpty(); + } + + @Test + void selectUnprimed_preservesEncounterOrderAndDeduplicates() { + PrimedClientRegistry registry = new PrimedClientRegistry(); + + assertThat(registry.selectUnprimed(Arrays.asList(SERVICE1_ASYNC, SERVICE1_SYNC, SERVICE1_ASYNC))) + .containsExactly(SERVICE1_ASYNC, SERVICE1_SYNC); + } + + @Test + void selectUnprimed_ignoresNullNames() { + PrimedClientRegistry registry = new PrimedClientRegistry(); + + assertThat(registry.selectUnprimed(Arrays.asList(SERVICE1_SYNC, null, SERVICE1_ASYNC))) + .containsExactly(SERVICE1_SYNC, SERVICE1_ASYNC); + } + + @Test + void selectUnprimed_emptyInput_returnsEmpty() { + PrimedClientRegistry registry = new PrimedClientRegistry(); + + assertThat(registry.selectUnprimed(Collections.emptyList())).isEmpty(); + } + + @Test + void separateInstances_doNotShareState() { + PrimedClientRegistry first = new PrimedClientRegistry(); + first.markPrimed(Collections.singletonList(SERVICE1_SYNC)); + + PrimedClientRegistry second = new PrimedClientRegistry(); + + assertThat(second.selectUnprimed(Collections.singletonList(SERVICE1_SYNC))) + .containsExactly(SERVICE1_SYNC); + } +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpInvokerTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpInvokerTest.java new file mode 100644 index 000000000000..bcffe3b1d571 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpInvokerTest.java @@ -0,0 +1,207 @@ +/* + * 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.crac; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.Set; +import org.apache.logging.log4j.Level; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.crac.SdkWarmUpProvider; +import software.amazon.awssdk.testutils.LogCaptor; + +class TargetedWarmUpInvokerTest { + + private static final String SERVICE1_SYNC = "software.amazon.awssdk.services.service1.Service1Client"; + private static final String SERVICE1_ASYNC = "software.amazon.awssdk.services.service1.Service1AsyncClient"; + private static final String SERVICE2_SYNC = "software.amazon.awssdk.services.service2.Service2Client"; + + @Test + void invoke_syncClassRequested_warmsSyncClientTypeOnly() { + RecordingProvider service1 = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + + TargetedWarmUpResult result = invokerLoading(service1).invoke(Arrays.asList(SERVICE1_SYNC)); + + assertThat(service1.warmedTypes()).containsExactly(ClientType.SYNC); + assertThat(result.matchedClientTypes()).containsExactly(ClientType.SYNC); + assertThat(result.warmedClientNames()).containsExactly(SERVICE1_SYNC); + } + + @Test + void invoke_asyncClassRequested_warmsAsyncClientTypeOnly() { + RecordingProvider service1 = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + + TargetedWarmUpResult result = invokerLoading(service1).invoke(Arrays.asList(SERVICE1_ASYNC)); + + assertThat(service1.warmedTypes()).containsExactly(ClientType.ASYNC); + assertThat(result.matchedClientTypes()).containsExactly(ClientType.ASYNC); + assertThat(result.warmedClientNames()).containsExactly(SERVICE1_ASYNC); + } + + @Test + void invoke_bothClassesRequested_warmsBothClientTypes() { + RecordingProvider service1 = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + + TargetedWarmUpResult result = + invokerLoading(service1).invoke(Arrays.asList(SERVICE1_SYNC, SERVICE1_ASYNC)); + + assertThat(service1.warmedTypes()).containsExactlyInAnyOrder(ClientType.SYNC, ClientType.ASYNC); + assertThat(result.matchedClientTypes()).containsExactlyInAnyOrder(ClientType.SYNC, ClientType.ASYNC); + assertThat(result.warmedClientNames()).containsExactly(SERVICE1_SYNC, SERVICE1_ASYNC); + } + + @Test + void invoke_classAcrossTwoProviders_warmsOnlyTheMatchingProvider() { + RecordingProvider service1 = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + RecordingProvider service2 = new RecordingProvider(SERVICE2_SYNC, null); + + invokerLoading(service1, service2).invoke(Arrays.asList(SERVICE2_SYNC)); + + assertThat(service1.warmedTypes()).isEmpty(); + assertThat(service2.warmedTypes()).containsExactly(ClientType.SYNC); + } + + @Test + void invoke_unmatchedClass_logsWarnAndDoesNotThrow() { + RecordingProvider service1 = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + + try (LogCaptor logCaptor = LogCaptor.create(Level.WARN)) { + TargetedWarmUpResult result = + invokerLoading(service1).invoke(Arrays.asList("com.example.NotAClient")); + + assertThat(result.matchedClientTypes()).isEmpty(); + assertThat(result.warmedClientNames()).isEmpty(); + assertThat(service1.warmedTypes()).isEmpty(); + assertThat(logCaptor.loggedEvents()) + .anyMatch(event -> event.getLevel() == Level.WARN + && event.getMessage().getFormattedMessage().contains("com.example.NotAClient")); + } + } + + @Test + void invoke_whenProviderWarmUpThrows_stillReturnsMatchedClientType() { + SdkWarmUpProvider throwing = new TestProvider(SERVICE1_SYNC, SERVICE1_ASYNC) { + @Override + public void warmUpClient(ClientType clientType) { + throw new RuntimeException("boom"); + } + }; + + // A throwing warmUpClient must not stop the follow-on HTTP warm-up: the matched client type is still returned so + // the caller can narrow the HTTP warmers. + TargetedWarmUpResult result = invokerLoading(throwing).invoke(Arrays.asList(SERVICE1_SYNC)); + + assertThat(result.matchedClientTypes()).containsExactly(ClientType.SYNC); + } + + @Test + void invoke_whenProviderWarmUpThrows_doesNotReportClientWarmed() { + SdkWarmUpProvider throwing = new TestProvider(SERVICE1_SYNC, SERVICE1_ASYNC) { + @Override + public void warmUpClient(ClientType clientType) { + throw new RuntimeException("boom"); + } + }; + + TargetedWarmUpResult result = invokerLoading(throwing).invoke(Arrays.asList(SERVICE1_SYNC)); + + assertThat(result.warmedClientNames()).isEmpty(); + } + + @Test + void invoke_mixedSuccessAndFailure_reportsOnlySuccessfulClientWarmed() { + RecordingProvider healthy = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + SdkWarmUpProvider throwing = new TestProvider(SERVICE2_SYNC, null) { + @Override + public void warmUpClient(ClientType clientType) { + throw new RuntimeException("boom"); + } + }; + + TargetedWarmUpResult result = + invokerLoading(healthy, throwing).invoke(Arrays.asList(SERVICE1_SYNC, SERVICE2_SYNC)); + + assertThat(result.warmedClientNames()).containsExactly(SERVICE1_SYNC); + assertThat(result.matchedClientTypes()).containsExactly(ClientType.SYNC); + } + + @Test + void invoke_emptyRequest_isNoOp() { + RecordingProvider service1 = new RecordingProvider(SERVICE1_SYNC, SERVICE1_ASYNC); + + TargetedWarmUpResult result = invokerLoading(service1).invoke(Collections.emptyList()); + + assertThat(result.matchedClientTypes()).isEmpty(); + assertThat(result.warmedClientNames()).isEmpty(); + assertThat(service1.warmedTypes()).isEmpty(); + } + + private TargetedWarmUpInvoker invokerLoading(SdkWarmUpProvider... providers) { + WarmUpServiceLoader loader = new WarmUpServiceLoader() { + @Override + Iterator loadProviders() { + return Arrays.asList(providers).iterator(); + } + }; + return new TargetedWarmUpInvoker(loader); + } + + private static class TestProvider implements SdkWarmUpProvider { + private final String syncName; + private final String asyncName; + + TestProvider(String syncName, String asyncName) { + this.syncName = syncName; + this.asyncName = asyncName; + } + + @Override + public String syncClientClassName() { + return syncName; + } + + @Override + public String asyncClientClassName() { + return asyncName; + } + + @Override + public void warmUpClient(ClientType clientType) { + } + } + + private static final class RecordingProvider extends TestProvider { + private final Set warmed = EnumSet.noneOf(ClientType.class); + + RecordingProvider(String syncName, String asyncName) { + super(syncName, asyncName); + } + + @Override + public void warmUpClient(ClientType clientType) { + warmed.add(clientType); + } + + Set warmedTypes() { + return warmed; + } + } +} diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpResultTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpResultTest.java new file mode 100644 index 000000000000..9e88b276b689 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/crac/TargetedWarmUpResultTest.java @@ -0,0 +1,29 @@ +/* + * 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.crac; + +import nl.jqno.equalsverifier.EqualsVerifier; +import org.junit.jupiter.api.Test; + +public class TargetedWarmUpResultTest { + + @Test + public void equalsHashCode() { + EqualsVerifier.forClass(TargetedWarmUpResult.class) + .withNonnullFields("matchedClientTypes", "warmedClientNames") + .verify(); + } +} diff --git a/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/CodingConventionWithSuppressionTest.java b/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/CodingConventionWithSuppressionTest.java index edfff3c08bf5..90f476490950 100644 --- a/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/CodingConventionWithSuppressionTest.java +++ b/test/architecture-tests/src/test/java/software/amazon/awssdk/archtests/CodingConventionWithSuppressionTest.java @@ -61,7 +61,8 @@ public class CodingConventionWithSuppressionTest { ArchUtils.classNameToPattern(KnownContentLengthAsyncRequestBodySubscriber.class), ArchUtils.classNameToPattern(UnknownContentLengthAsyncRequestBodySubscriber.class), ArchUtils.classNameToPattern(CopyObjectHelper.class), - ArchUtils.classNameToPattern("software.amazon.awssdk.core.internal.crac.WarmUpDiscovery"))); + ArchUtils.classNameToPattern("software.amazon.awssdk.core.internal.crac.WarmUpDiscovery"), + ArchUtils.classNameToPattern("software.amazon.awssdk.core.internal.crac.TargetedWarmUpInvoker"))); private static final Set ALLOWED_ERROR_LOG_SUPPRESSION = new HashSet<>( Arrays.asList( diff --git a/test/warmup-tests/pom.xml b/test/warmup-tests/pom.xml index b4c10b292038..d292904c998f 100644 --- a/test/warmup-tests/pom.xml +++ b/test/warmup-tests/pom.xml @@ -48,7 +48,6 @@ software.amazon.awssdk sdk-core ${awsjavasdk.version} - test software.amazon.awssdk @@ -80,6 +79,18 @@ ${awsjavasdk.version} test + + software.amazon.awssdk + sts + ${awsjavasdk.version} + test + + + software.amazon.awssdk + dynamodb + ${awsjavasdk.version} + test + org.junit.jupiter junit-jupiter diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/IdempotenceSyncClient.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/IdempotenceSyncClient.java new file mode 100644 index 000000000000..79f37f03015e --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/IdempotenceSyncClient.java @@ -0,0 +1,25 @@ +/* + * 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 software.amazon.awssdk.core.SdkClient; + +/** + * Test-only client matching {@link IdempotenceWarmUpProvider#syncClientClassName()}, primed only by + * {@link SdkWarmUpPrimeIdempotenceTest}. + */ +public interface IdempotenceSyncClient extends SdkClient { +} diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/IdempotenceWarmUpProvider.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/IdempotenceWarmUpProvider.java new file mode 100644 index 000000000000..e3295a3aa413 --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/IdempotenceWarmUpProvider.java @@ -0,0 +1,54 @@ +/* + * 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 java.util.concurrent.atomic.AtomicInteger; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.crac.SdkWarmUpProvider; + +/** + * Test-only {@link SdkWarmUpProvider} for {@link SdkWarmUpPrimeIdempotenceTest}. Matches {@link IdempotenceSyncClient} + * only and counts sync warms; performs no I/O. + */ +public final class IdempotenceWarmUpProvider implements SdkWarmUpProvider { + + private static final AtomicInteger SYNC_WARMS = new AtomicInteger(); + + public static int syncWarmCount() { + return SYNC_WARMS.get(); + } + + @Override + public void warmUp() { + } + + @Override + public String syncClientClassName() { + return "software.amazon.awssdk.http.warmup.IdempotenceSyncClient"; + } + + @Override + public String asyncClientClassName() { + return "software.amazon.awssdk.http.warmup.IdempotenceAsyncClient"; + } + + @Override + public void warmUpClient(ClientType clientType) { + if (clientType == ClientType.SYNC) { + SYNC_WARMS.incrementAndGet(); + } + } +} diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/RetrySyncClient.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/RetrySyncClient.java new file mode 100644 index 000000000000..13f8cea0e151 --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/RetrySyncClient.java @@ -0,0 +1,24 @@ +/* + * 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 software.amazon.awssdk.core.SdkClient; + +/** + * Test-only client matching {@link RetryWarmUpProvider#syncClientClassName()}. + */ +public interface RetrySyncClient extends SdkClient { +} diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/RetryWarmUpProvider.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/RetryWarmUpProvider.java new file mode 100644 index 000000000000..0793a8df35b1 --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/RetryWarmUpProvider.java @@ -0,0 +1,62 @@ +/* + * 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 java.util.concurrent.atomic.AtomicInteger; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.crac.SdkWarmUpProvider; + +/** + * Test-only provider whose first {@code warmUpClient(SYNC)} call throws; later calls succeed. + */ +public final class RetryWarmUpProvider implements SdkWarmUpProvider { + + private static final AtomicInteger SYNC_ATTEMPTS = new AtomicInteger(); + private static final AtomicInteger SYNC_SUCCESSES = new AtomicInteger(); + + public static int syncAttemptCount() { + return SYNC_ATTEMPTS.get(); + } + + public static int syncSuccessCount() { + return SYNC_SUCCESSES.get(); + } + + @Override + public void warmUp() { + } + + @Override + public String syncClientClassName() { + return "software.amazon.awssdk.http.warmup.RetrySyncClient"; + } + + @Override + public String asyncClientClassName() { + return null; + } + + @Override + public void warmUpClient(ClientType clientType) { + if (clientType != ClientType.SYNC) { + return; + } + if (SYNC_ATTEMPTS.incrementAndGet() == 1) { + throw new RuntimeException("Simulated transient warm-up failure (test)"); + } + SYNC_SUCCESSES.incrementAndGet(); + } +} diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveAsyncClient.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveAsyncClient.java new file mode 100644 index 000000000000..b7937496bdac --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveAsyncClient.java @@ -0,0 +1,25 @@ +/* + * 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 software.amazon.awssdk.core.SdkClient; + +/** + * Test-only client matching {@link SelectiveWarmUpProvider#asyncClientClassName()}, primed only by + * {@link SdkWarmUpPrimeSelectiveTest}. + */ +public interface SelectiveAsyncClient extends SdkClient { +} diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveSyncClient.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveSyncClient.java new file mode 100644 index 000000000000..e04839df47a1 --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveSyncClient.java @@ -0,0 +1,25 @@ +/* + * 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 software.amazon.awssdk.core.SdkClient; + +/** + * Test-only client matching {@link SelectiveWarmUpProvider#syncClientClassName()}, primed only by + * {@link SdkWarmUpPrimeSelectiveTest}. + */ +public interface SelectiveSyncClient extends SdkClient { +} diff --git a/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveWarmUpProvider.java b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveWarmUpProvider.java new file mode 100644 index 000000000000..21b038807fcd --- /dev/null +++ b/test/warmup-tests/src/main/java/software/amazon/awssdk/http/warmup/SelectiveWarmUpProvider.java @@ -0,0 +1,61 @@ +/* + * 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 java.util.concurrent.atomic.AtomicInteger; +import software.amazon.awssdk.core.ClientType; +import software.amazon.awssdk.core.crac.SdkWarmUpProvider; + +/** + * Test-only {@link SdkWarmUpProvider} for {@link SdkWarmUpPrimeSelectiveTest}. Matches {@link SelectiveSyncClient} and + * {@link SelectiveAsyncClient} and counts warms per client type; performs no I/O. + */ +public final class SelectiveWarmUpProvider implements SdkWarmUpProvider { + + private static final AtomicInteger SYNC_WARMS = new AtomicInteger(); + private static final AtomicInteger ASYNC_WARMS = new AtomicInteger(); + + public static int syncWarmCount() { + return SYNC_WARMS.get(); + } + + public static int asyncWarmCount() { + return ASYNC_WARMS.get(); + } + + @Override + public void warmUp() { + } + + @Override + public String syncClientClassName() { + return "software.amazon.awssdk.http.warmup.SelectiveSyncClient"; + } + + @Override + public String asyncClientClassName() { + return "software.amazon.awssdk.http.warmup.SelectiveAsyncClient"; + } + + @Override + public void warmUpClient(ClientType clientType) { + if (clientType == ClientType.SYNC) { + SYNC_WARMS.incrementAndGet(); + } else if (clientType == ClientType.ASYNC) { + ASYNC_WARMS.incrementAndGet(); + } + } +} diff --git a/test/warmup-tests/src/main/resources/META-INF/services/software.amazon.awssdk.core.crac.SdkWarmUpProvider b/test/warmup-tests/src/main/resources/META-INF/services/software.amazon.awssdk.core.crac.SdkWarmUpProvider new file mode 100644 index 000000000000..3e6b812379bc --- /dev/null +++ b/test/warmup-tests/src/main/resources/META-INF/services/software.amazon.awssdk.core.crac.SdkWarmUpProvider @@ -0,0 +1,3 @@ +software.amazon.awssdk.http.warmup.IdempotenceWarmUpProvider +software.amazon.awssdk.http.warmup.SelectiveWarmUpProvider +software.amazon.awssdk.http.warmup.RetryWarmUpProvider diff --git a/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeIdempotenceTest.java b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeIdempotenceTest.java new file mode 100644 index 000000000000..2fa65bf2187b --- /dev/null +++ b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeIdempotenceTest.java @@ -0,0 +1,62 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.crac.SdkWarmUp; + +/** + * Verifies {@link SdkWarmUp#prime(Class...)} warms a client at most once per JVM. Uses a dedicated + * {@link IdempotenceSyncClient} that no other test primes, so the "first warms, repeat no-ops" transition is observable + * regardless of execution order or JVM sharing. + */ +class SdkWarmUpPrimeIdempotenceTest { + + private String savedRegionProperty; + + @BeforeEach + void setUp() { + savedRegionProperty = System.getProperty("aws.region"); + System.setProperty("aws.region", "warmup-idempotence-test"); + } + + @AfterEach + void tearDown() { + if (savedRegionProperty != null) { + System.setProperty("aws.region", savedRegionProperty); + } else { + System.clearProperty("aws.region"); + } + } + + @Test + void prime_sameClientTwice_warmsProviderExactlyOnce() { + SdkWarmUp.prime(IdempotenceSyncClient.class); + assertThat(IdempotenceWarmUpProvider.syncWarmCount()) + .as("first prime warms the sync client type once") + .isEqualTo(1); + + // Second call for the same client must be a no-op: it is already recorded as primed. + SdkWarmUp.prime(IdempotenceSyncClient.class); + assertThat(IdempotenceWarmUpProvider.syncWarmCount()) + .as("second prime of the same client does not warm again") + .isEqualTo(1); + } +} diff --git a/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeRetryTest.java b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeRetryTest.java new file mode 100644 index 000000000000..93f0423b860f --- /dev/null +++ b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeRetryTest.java @@ -0,0 +1,64 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.crac.SdkWarmUp; + +/** + * Verifies a failed {@link SdkWarmUp#prime(Class...)} warm-up is retried on the next call. + */ +class SdkWarmUpPrimeRetryTest { + + private String savedRegionProperty; + + @BeforeEach + void setUp() { + savedRegionProperty = System.getProperty("aws.region"); + System.setProperty("aws.region", "warmup-retry-test"); + } + + @AfterEach + void tearDown() { + if (savedRegionProperty != null) { + System.setProperty("aws.region", savedRegionProperty); + } else { + System.clearProperty("aws.region"); + } + } + + @Test + void prime_afterWarmUpFailure_retriesOnNextCallThenStops() { + // First call: the provider throws, so the client is not recorded as primed. + assertThatCode(() -> SdkWarmUp.prime(RetrySyncClient.class)).doesNotThrowAnyException(); + assertThat(RetryWarmUpProvider.syncAttemptCount()).as("first call attempts the warm").isEqualTo(1); + assertThat(RetryWarmUpProvider.syncSuccessCount()).as("first call fails").isEqualTo(0); + + // Second call: retried; this time it succeeds. + SdkWarmUp.prime(RetrySyncClient.class); + assertThat(RetryWarmUpProvider.syncAttemptCount()).as("failure is retried").isEqualTo(2); + assertThat(RetryWarmUpProvider.syncSuccessCount()).as("retry succeeds").isEqualTo(1); + + // Third call: already primed, no further attempt. + SdkWarmUp.prime(RetrySyncClient.class); + assertThat(RetryWarmUpProvider.syncAttemptCount()).as("successful warm is not repeated").isEqualTo(2); + } +} diff --git a/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeSelectiveTest.java b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeSelectiveTest.java new file mode 100644 index 000000000000..359532c44158 --- /dev/null +++ b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeSelectiveTest.java @@ -0,0 +1,65 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.core.crac.SdkWarmUp; + +/** + * Verifies that when {@link SdkWarmUp#prime(Class...)} is called with a batch mixing an already-primed client and a new + * one, only the new client is warmed. Uses dedicated clients that no other test primes. + */ +class SdkWarmUpPrimeSelectiveTest { + + private String savedRegionProperty; + + @BeforeEach + void setUp() { + savedRegionProperty = System.getProperty("aws.region"); + System.setProperty("aws.region", "warmup-selective-test"); + } + + @AfterEach + void tearDown() { + if (savedRegionProperty != null) { + System.setProperty("aws.region", savedRegionProperty); + } else { + System.clearProperty("aws.region"); + } + } + + @Test + void prime_previouslyPrimedClientInNewBatch_warmsOnlyTheNewClient() { + // Prime the sync client first. + SdkWarmUp.prime(SelectiveSyncClient.class); + assertThat(SelectiveWarmUpProvider.syncWarmCount()).isEqualTo(1); + assertThat(SelectiveWarmUpProvider.asyncWarmCount()).isEqualTo(0); + + // Now prime a batch containing the already-primed sync client plus a new async client. + SdkWarmUp.prime(SelectiveSyncClient.class, SelectiveAsyncClient.class); + + assertThat(SelectiveWarmUpProvider.syncWarmCount()) + .as("already-primed sync client is not warmed again") + .isEqualTo(1); + assertThat(SelectiveWarmUpProvider.asyncWarmCount()) + .as("the new async client is warmed once") + .isEqualTo(1); + } +} diff --git a/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeTargetedTest.java b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeTargetedTest.java new file mode 100644 index 000000000000..cf37b5b1910d --- /dev/null +++ b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/SdkWarmUpPrimeTargetedTest.java @@ -0,0 +1,196 @@ +/* + * 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 org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.ServiceLoader; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.core.SdkClient; +import software.amazon.awssdk.core.crac.SdkWarmUp; +import software.amazon.awssdk.core.crac.SdkWarmUpProvider; +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.sts.StsAsyncClient; +import software.amazon.awssdk.services.sts.StsClient; + +/** + * End-to-end integration test of {@link SdkWarmUp#prime(Class...)} with real service clients (STS, DynamoDB) whose + * generated {@link SdkWarmUpProvider} implementations are on the classpath via ServiceLoader. + */ +class SdkWarmUpPrimeTargetedTest { + + private WireMockServer mockServer; + private String savedRegionProperty; + + @BeforeEach + void setUp() { + mockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + mockServer.start(); + mockServer.stubFor(any(anyUrl()).willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "https://aws.amazon.com/iam"))); + + savedRegionProperty = System.getProperty("aws.region"); + System.setProperty("aws.region", "warmup-integration-test"); + } + + @AfterEach + void tearDown() { + mockServer.stop(); + if (savedRegionProperty != null) { + System.setProperty("aws.region", savedRegionProperty); + } else { + System.clearProperty("aws.region"); + } + } + + /** + * Sanity check: ServiceLoader must discover at least the STS and DynamoDB providers we depend on. + */ + @Test + void serviceLoader_discoversExpectedProviders() { + int count = 0; + for (SdkWarmUpProvider ignored : ServiceLoader.load(SdkWarmUpProvider.class)) { + count++; + } + // STS has 1 provider, DynamoDB has 2 (DynamoDb + DynamoDbStreams). + assertThat(count).isGreaterThanOrEqualTo(3); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("singleClientCases") + void prime_withSingleClient_completesWithoutError(String description, Class client) { + assertThatCode(() -> SdkWarmUp.prime(client)).doesNotThrowAnyException(); + } + + private static Stream singleClientCases() { + return Stream.of( + arguments("STS sync", StsClient.class), + arguments("STS async", StsAsyncClient.class), + arguments("DynamoDB sync", DynamoDbClient.class), + arguments("DynamoDB async", DynamoDbAsyncClient.class) + ); + } + + @Test + void prime_withMultipleServices_completesWithoutError() { + assertThatCode(() -> SdkWarmUp.prime(StsClient.class, DynamoDbClient.class, StsAsyncClient.class)) + .doesNotThrowAnyException(); + } + + @Test + void prime_calledTwiceWithSameClient_isIdempotent() { + SdkWarmUp.prime(StsClient.class); + + // Second call should be a no-op (client already recorded as primed). + assertThatCode(() -> SdkWarmUp.prime(StsClient.class)).doesNotThrowAnyException(); + } + + @Test + void prime_calledWithNewClientAfterPrevious_primesOnlyNewClient() { + SdkWarmUp.prime(StsClient.class); + + // DynamoDB is new; STS should not be re-primed. + assertThatCode(() -> SdkWarmUp.prime(DynamoDbClient.class, StsClient.class)).doesNotThrowAnyException(); + } + + @Test + void prime_concurrentCalls_allCompleteSuccessfully() throws InterruptedException { + int threadCount = 8; + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + List threads = new ArrayList<>(); + List failures = Collections.synchronizedList(new ArrayList<>()); + AtomicInteger completed = new AtomicInteger(); + + Class[] clients = {StsClient.class, StsAsyncClient.class, DynamoDbClient.class, DynamoDbAsyncClient.class}; + for (int i = 0; i < threadCount; i++) { + @SuppressWarnings("unchecked") + Class client = (Class) clients[i % clients.length]; + Thread thread = new Thread(() -> { + try { + start.await(); + SdkWarmUp.prime(client); + completed.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + failures.add(t); + } finally { + done.countDown(); + } + }); + threads.add(thread); + thread.start(); + } + + start.countDown(); + done.await(); + for (Thread thread : threads) { + thread.join(); + } + + assertThat(failures).as("no prime(Class...) call threw").isEmpty(); + assertThat(completed.get()).as("every prime(Class...) call completed").isEqualTo(threadCount); + } + + @Test + void prime_withUnmatchedClient_isNoOpAndSendsNoRequest() { + mockServer.stubFor(any(anyUrl()).willReturn(aResponse().withStatus(200))); + + assertThatCode(() -> SdkWarmUp.prime(UnregisteredClient.class)).doesNotThrowAnyException(); + + // No provider matches, so no HTTP warmer runs and nothing hits the mock server. + mockServer.verify(0, anyRequestedFor(anyUrl())); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("noOpInputCases") + void prime_withNoOpInput_doesNotThrow(String description, Class[] clients) { + assertThatCode(() -> SdkWarmUp.prime(clients)).doesNotThrowAnyException(); + } + + private static Stream noOpInputCases() { + return Stream.of( + arguments("null array", (Class[]) null), + arguments("empty array", new Class[0]) + ); + } + + interface UnregisteredClient extends SdkClient { + } +} +