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 extends SdkClient>... 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 extends SdkClient> 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 extends SdkClient> 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 extends SdkClient> client = (Class extends SdkClient>) 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 extends SdkClient>[] clients) {
+ assertThatCode(() -> SdkWarmUp.prime(clients)).doesNotThrowAnyException();
+ }
+
+ private static Stream noOpInputCases() {
+ return Stream.of(
+ arguments("null array", (Class extends SdkClient>[]) null),
+ arguments("empty array", new Class[0])
+ );
+ }
+
+ interface UnregisteredClient extends SdkClient {
+ }
+}
+