Skip to content

Commit b1b20e3

Browse files
delegates cross region bucket location determination to SDK (#574)
1 parent 558f42c commit b1b20e3

File tree

3 files changed

+15
-220
lines changed

3 files changed

+15
-220
lines changed

src/main/java/software/amazon/nio/spi/s3/S3ClientProvider.java

+5-149
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,22 @@
55

66
package software.amazon.nio.spi.s3;
77

8-
import static java.util.concurrent.TimeUnit.MINUTES;
9-
import static software.amazon.nio.spi.s3.util.TimeOutUtils.logAndGenerateExceptionOnTimeOut;
10-
118
import com.github.benmanes.caffeine.cache.Cache;
129
import com.github.benmanes.caffeine.cache.Caffeine;
13-
import java.net.URI;
1410
import java.time.Duration;
15-
import java.util.Optional;
16-
import java.util.concurrent.ExecutionException;
17-
import java.util.concurrent.TimeoutException;
18-
import org.slf4j.Logger;
19-
import org.slf4j.LoggerFactory;
20-
import software.amazon.awssdk.regions.Region;
2111
import software.amazon.awssdk.services.s3.S3AsyncClient;
2212
import software.amazon.awssdk.services.s3.S3CrtAsyncClientBuilder;
23-
import software.amazon.awssdk.services.s3.model.HeadBucketResponse;
24-
import software.amazon.awssdk.services.s3.model.S3Exception;
2513
import software.amazon.nio.spi.s3.config.S3NioSpiConfiguration;
2614

2715
/**
28-
* Factory/builder class that creates async S3 clients. It also provides
29-
* default clients that can be used for basic operations (e.g. bucket discovery).
16+
* Creates async S3 clients used by this library.
3017
*/
3118
public class S3ClientProvider {
3219

33-
private static final Logger logger = LoggerFactory.getLogger(S3ClientProvider.class);
34-
3520
/**
3621
* Default asynchronous client using the "<a href="https://s3.us-east-1.amazonaws.com">...</a>" endpoint
3722
*/
23+
@Deprecated
3824
protected S3AsyncClient universalClient;
3925

4026
/**
@@ -49,10 +35,6 @@ public class S3ClientProvider {
4935
S3AsyncClient.crtBuilder()
5036
.crossRegionAccessEnabled(true);
5137

52-
private final Cache<String, String> bucketRegionCache = Caffeine.newBuilder()
53-
.maximumSize(16)
54-
.expireAfterWrite(Duration.ofMinutes(30))
55-
.build();
5638

5739
private final Cache<String, CacheableS3Client> bucketClientCache = Caffeine.newBuilder()
5840
.maximumSize(4)
@@ -61,35 +43,12 @@ public class S3ClientProvider {
6143

6244
public S3ClientProvider(S3NioSpiConfiguration c) {
6345
this.configuration = (c == null) ? new S3NioSpiConfiguration() : c;
64-
this.universalClient = S3AsyncClient.builder()
65-
.endpointOverride(URI.create("https://s3.us-east-1.amazonaws.com"))
66-
.crossRegionAccessEnabled(true)
67-
.region(Region.US_EAST_1)
68-
.build();
6946
}
7047

7148
public void asyncClientBuilder(final S3CrtAsyncClientBuilder builder) {
7249
asyncClientBuilder = builder;
7350
}
7451

75-
/**
76-
* This method returns a universal client bound to the us-east-1 region
77-
* that can be used by certain S3 operations for discovery such as getBucketLocation.
78-
*
79-
* @return an S3AsyncClient bound to us-east-1
80-
*/
81-
S3AsyncClient universalClient() {
82-
return universalClient;
83-
}
84-
85-
/**
86-
* Sets the fallback client used to make {@code S3AsyncClient#getBucketLocation()} calls. Typically, this would
87-
* only be set for testing purposes to use a {@code Mock} or {@code Spy} class.
88-
* @param client the client to be used for getBucketLocation calls.
89-
*/
90-
void universalClient(S3AsyncClient client) {
91-
this.universalClient = client;
92-
}
9352

9453
/**
9554
* Generates a sync client for the named bucket using a client configured by the default region configuration chain.
@@ -98,108 +57,15 @@ void universalClient(S3AsyncClient client) {
9857
* @return an S3 client appropriate for the region of the named bucket
9958
*/
10059
protected S3AsyncClient generateClient(String bucket) {
101-
try {
102-
return generateClient(bucket, S3AsyncClient.create());
103-
} catch (ExecutionException e) {
104-
throw new RuntimeException(e);
105-
} catch (InterruptedException e) {
106-
Thread.currentThread().interrupt();
107-
throw new RuntimeException(e);
108-
}
109-
}
110-
111-
/**
112-
* Generate an async client for the named bucket using a provided client to
113-
* determine the location of the named client
114-
*
115-
* @param bucketName the name of the bucket to make the client for
116-
* @param locationClient the client used to determine the location of the
117-
* named bucket, recommend using {@code S3ClientProvider#UNIVERSAL_CLIENT}
118-
* @return an S3 client appropriate for the region of the named bucket
119-
*/
120-
S3AsyncClient generateClient(String bucketName, S3AsyncClient locationClient)
121-
throws ExecutionException, InterruptedException {
122-
logger.debug("generating client for bucket: '{}'", bucketName);
123-
124-
String bucketLocation = null;
125-
if (configuration.endpointUri() == null) {
126-
// we try to locate a bucket only if no endpoint is provided, which means we are dealing with AWS S3 buckets
127-
bucketLocation = getBucketLocation(bucketName, locationClient);
128-
129-
if (bucketLocation == null) {
130-
// if here, no S3 nor other client has been created yet, and we do not
131-
// have a location; we'll let it figure out from the profile region
132-
logger.warn("Unable to determine the region of bucket: '{}'. Generating a client for the profile region.",
133-
bucketName);
134-
}
135-
}
136-
137-
var client = bucketClientCache.getIfPresent(bucketName);
60+
var client = bucketClientCache.getIfPresent(bucket);
13861
if (client != null && !client.isClosed()) {
13962
return client;
14063
} else {
14164
if (client != null && client.isClosed()) {
142-
bucketClientCache.invalidate(bucketName); // remove the closed client from the cache
143-
}
144-
String r = Optional.ofNullable(bucketLocation).orElse(configuration.getRegion());
145-
return bucketClientCache.get(bucketName, b -> new CacheableS3Client(configureCrtClientForRegion(r)));
146-
}
147-
}
148-
149-
private String getBucketLocation(String bucketName, S3AsyncClient locationClient)
150-
throws ExecutionException, InterruptedException {
151-
152-
if (bucketRegionCache.getIfPresent(bucketName) != null) {
153-
return bucketRegionCache.getIfPresent(bucketName);
154-
}
155-
156-
logger.debug("checking if the bucket is in the same region as the providedClient using HeadBucket");
157-
try (var client = locationClient) {
158-
final HeadBucketResponse response = client
159-
.headBucket(builder -> builder.bucket(bucketName))
160-
.get(configuration.getTimeoutLow(), MINUTES);
161-
bucketRegionCache.put(bucketName, response.bucketRegion());
162-
return response.bucketRegion();
163-
164-
} catch (TimeoutException e) {
165-
throw logAndGenerateExceptionOnTimeOut(
166-
logger,
167-
"generateClient",
168-
configuration.getTimeoutLow(),
169-
MINUTES);
170-
} catch (Throwable t) {
171-
172-
if (t instanceof ExecutionException &&
173-
t.getCause() instanceof S3Exception &&
174-
((S3Exception) t.getCause()).statusCode() == 301) { // you got a redirect, the region should be in the header
175-
logger.debug("HeadBucket was unsuccessful, redirect received, attempting to extract x-amz-bucket-region header");
176-
S3Exception s3e = (S3Exception) t.getCause();
177-
final var matchingHeaders = s3e.awsErrorDetails().sdkHttpResponse().matchingHeaders("x-amz-bucket-region");
178-
if (matchingHeaders != null && !matchingHeaders.isEmpty()) {
179-
bucketRegionCache.put(bucketName, matchingHeaders.get(0));
180-
return matchingHeaders.get(0);
181-
}
182-
} else if (t instanceof ExecutionException &&
183-
t.getCause() instanceof S3Exception &&
184-
((S3Exception) t.getCause()).statusCode() == 403) { // HeadBucket was forbidden
185-
logger.debug("HeadBucket forbidden. Attempting a call to GetBucketLocation using the UNIVERSAL_CLIENT");
186-
try {
187-
String location = universalClient.getBucketLocation(builder -> builder.bucket(bucketName))
188-
.get(configuration.getTimeoutLow(), MINUTES).locationConstraintAsString();
189-
bucketRegionCache.put(bucketName, location);
190-
} catch (TimeoutException e) {
191-
throw logAndGenerateExceptionOnTimeOut(
192-
logger,
193-
"generateClient",
194-
configuration.getTimeoutLow(),
195-
MINUTES);
196-
}
197-
} else {
198-
// didn't handle the exception - rethrow it
199-
throw t;
65+
bucketClientCache.invalidate(bucket); // remove the closed client from the cache
20066
}
67+
return bucketClientCache.get(bucket, b -> new CacheableS3Client(configureCrtClient().build()));
20168
}
202-
return "";
20369
}
20470

20571
S3CrtAsyncClientBuilder configureCrtClient() {
@@ -216,14 +82,4 @@ S3CrtAsyncClientBuilder configureCrtClient() {
21682
return asyncClientBuilder.forcePathStyle(configuration.getForcePathStyle());
21783
}
21884

219-
private S3AsyncClient configureCrtClientForRegion(String regionName) {
220-
var region = getRegionFromRegionName(regionName);
221-
logger.debug("bucket region is: '{}'", region);
222-
return configureCrtClient().region(region).build();
223-
}
224-
225-
private static Region getRegionFromRegionName(String regionName) {
226-
return (regionName == null || regionName.isBlank()) ? null : Region.of(regionName);
227-
}
228-
22985
}

src/test/java/software/amazon/nio/spi/s3/FixedS3ClientProvider.java

-5
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,6 @@ public FixedS3ClientProvider(S3AsyncClient client) {
2121
this.client = client;
2222
}
2323

24-
@Override
25-
S3AsyncClient universalClient() {
26-
return client;
27-
}
28-
2924
@Override
3025
protected S3AsyncClient generateClient(String bucketName) {
3126
return client;

src/test/java/software/amazon/nio/spi/s3/S3ClientProviderTest.java

+10-66
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,21 @@
99
import static org.junit.jupiter.api.Assertions.assertNotNull;
1010
import static org.junit.jupiter.api.Assertions.assertNotSame;
1111
import static org.junit.jupiter.api.Assertions.assertSame;
12-
import static org.mockito.Mockito.inOrder;
13-
import static org.mockito.Mockito.mock;
1412
import static org.mockito.Mockito.spy;
1513
import static org.mockito.Mockito.times;
1614
import static org.mockito.Mockito.verify;
17-
import static org.mockito.Mockito.when;
18-
import static software.amazon.nio.spi.s3.S3Matchers.anyConsumer;
1915

2016
import java.net.URI;
21-
import java.util.concurrent.CompletableFuture;
22-
import java.util.concurrent.ExecutionException;
2317
import org.junit.jupiter.api.BeforeEach;
2418
import org.junit.jupiter.api.Test;
2519
import org.junit.jupiter.api.extension.ExtendWith;
26-
import org.mockito.Mock;
2720
import org.mockito.junit.jupiter.MockitoExtension;
2821
import software.amazon.awssdk.services.s3.S3AsyncClient;
29-
import software.amazon.awssdk.services.s3.model.GetBucketLocationResponse;
30-
import software.amazon.awssdk.services.s3.model.HeadBucketResponse;
31-
import software.amazon.awssdk.services.s3.model.S3Exception;
3222
import software.amazon.nio.spi.s3.config.S3NioSpiConfiguration;
3323

3424
@ExtendWith(MockitoExtension.class)
3525
public class S3ClientProviderTest {
3626

37-
@Mock
38-
S3AsyncClient mockClient; //client used to determine bucket location
39-
4027
S3ClientProvider provider;
4128

4229
@BeforeEach
@@ -50,87 +37,46 @@ public void initialization() {
5037

5138
assertNotNull(s3ClientProvider.configuration);
5239

53-
S3AsyncClient t = s3ClientProvider.universalClient();
54-
assertNotNull(t);
55-
5640
var config = new S3NioSpiConfiguration();
5741
assertSame(config, new S3ClientProvider(config).configuration);
5842
}
5943

6044
@Test
61-
public void testGenerateAsyncClientWithNoErrors() throws ExecutionException, InterruptedException {
62-
when(mockClient.headBucket(anyConsumer()))
63-
.thenReturn(CompletableFuture.completedFuture(
64-
HeadBucketResponse.builder().bucketRegion("us-west-2").build()));
65-
final var s3Client = provider.generateClient("test-bucket", mockClient);
45+
public void testGenerateAsyncClientWithNoErrors() {
46+
final var s3Client = provider.generateClient("test-bucket");
6647
assertNotNull(s3Client);
6748
}
6849

6950
@Test
70-
public void testGenerateClientIsCacheableClass() throws Exception {
71-
when(mockClient.headBucket(anyConsumer()))
72-
.thenReturn(CompletableFuture.completedFuture(
73-
HeadBucketResponse.builder().bucketRegion("us-west-2").build()));
74-
final var s3Client = provider.generateClient("test-bucket", mockClient);
51+
public void testGenerateClientIsCacheableClass() {
52+
final var s3Client = provider.generateClient("test-bucket");
7553
assertInstanceOf(CacheableS3Client.class, s3Client);
7654
}
7755

7856
@Test
79-
public void testGenerateClientCachesClients() throws Exception {
80-
when(mockClient.headBucket(anyConsumer()))
81-
.thenReturn(CompletableFuture.completedFuture(
82-
HeadBucketResponse.builder().bucketRegion("us-west-2").build()));
83-
final var s3Client = provider.generateClient("test-bucket", mockClient);
84-
final var s3Client2 = provider.generateClient("test-bucket", mockClient);
57+
public void testGenerateClientCachesClients() {
58+
final var s3Client = provider.generateClient("test-bucket");
59+
final var s3Client2 = provider.generateClient("test-bucket");
8560
assertSame(s3Client, s3Client2);
8661
}
8762

8863
@Test
89-
public void testClosedClientIsNotReused() throws ExecutionException, InterruptedException {
90-
when(mockClient.headBucket(anyConsumer()))
91-
.thenReturn(CompletableFuture.completedFuture(
92-
HeadBucketResponse.builder().bucketRegion("us-west-2").build()));
64+
public void testClosedClientIsNotReused() {
9365

94-
final var s3Client = provider.generateClient("test-bucket", mockClient);
66+
final var s3Client = provider.generateClient("test-bucket");
9567
assertNotNull(s3Client);
9668

9769
// now close the client
9870
s3Client.close();
9971

10072
// now generate a new client with the same bucket name
101-
final var s3Client2 = provider.generateClient("test-bucket", mockClient);
73+
final var s3Client2 = provider.generateClient("test-bucket");
10274
assertNotNull(s3Client2);
10375

10476
// assert it is not the closed client
10577
assertNotSame(s3Client, s3Client2);
10678
}
10779

108-
@Test
109-
public void testGenerateAsyncClientWith403Response() throws ExecutionException, InterruptedException {
110-
// when you get a forbidden response from HeadBucket
111-
when(mockClient.headBucket(anyConsumer())).thenReturn(
112-
CompletableFuture.failedFuture(S3Exception.builder().statusCode(403).build())
113-
);
114-
115-
// you should fall back to a get bucket location attempt from the universal client
116-
var mockUniversalClient = mock(S3AsyncClient.class);
117-
provider.universalClient(mockUniversalClient);
118-
when(mockUniversalClient.getBucketLocation(anyConsumer())).thenReturn(CompletableFuture.completedFuture(
119-
GetBucketLocationResponse.builder()
120-
.locationConstraint("us-west-2")
121-
.build()
122-
));
123-
124-
// which should get you a client
125-
final var s3Client = provider.generateClient("test-bucket", mockClient);
126-
assertNotNull(s3Client);
127-
128-
final var inOrder = inOrder(mockClient, mockUniversalClient);
129-
inOrder.verify(mockClient).headBucket(anyConsumer());
130-
inOrder.verify(mockUniversalClient).getBucketLocation(anyConsumer());
131-
inOrder.verifyNoMoreInteractions();
132-
}
133-
13480
@Test
13581
public void generateAsyncClientByEndpointBucketCredentials() {
13682
// GIVEN
@@ -144,7 +90,6 @@ public void generateAsyncClientByEndpointBucketCredentials() {
14490

14591
// THEN
14692
verify(BUILDER, times(1)).endpointOverride(URI.create("https://endpoint1:1010"));
147-
verify(BUILDER, times(1)).region(null);
14893

14994
// GIVEN
15095
BUILDER = spy(S3AsyncClient.crtBuilder());
@@ -156,6 +101,5 @@ public void generateAsyncClientByEndpointBucketCredentials() {
156101

157102
// THEN
158103
verify(BUILDER, times(1)).endpointOverride(URI.create("https://endpoint2:2020"));
159-
verify(BUILDER, times(1)).region(null);
160104
}
161105
}

0 commit comments

Comments
 (0)