Skip to content

Define S3 client with bucket and endpoint resolution#45783

Merged
nikitamikhaylov merged 23 commits intomasterfrom
s3-with-head-bucket
Feb 3, 2023
Merged

Define S3 client with bucket and endpoint resolution#45783
nikitamikhaylov merged 23 commits intomasterfrom
s3-with-head-bucket

Conversation

@antonio2368
Copy link
Member

@antonio2368 antonio2368 commented Jan 30, 2023

Changelog category (leave one):

  • Improvement

Changelog entry (a user-readable short description of the changes that goes to CHANGELOG.md):

Improve internal S3 client to correctly deduce regions and redirections for different types of URLs.

I defined a client that will try to detect region for the endpoint (if it's not explicitly defined in the URL or config) by using HeadBucket allowing us to use HeadObject correctly.
Regions will be cached for each bucket per client.

Additionally, I noticed that the SDK doesn't handle correctly code 301 when the style of URL changes.
For example, this never worked with CH:

SELECT * FROM s3('https://s3.amazonaws.com/clickhouse-public-datasets/wikistat/partitioned/wikistat201801.native.zst') limit 10

because redirect is to a different style URL (virtual address) while AWS SDK simply changes URI authority with the location in the body.
I defined a similar logic with cache as for the region and now this also correctly works, even with HeadObject.

To verify that both caches are working, you can define an S3 table and check the performance difference between first and second run (if an URL without region is used and even more if it's redirected).

Things left to do:

  • go through the code and try to include it in other places (e.g. BackupS3)
  • see if tests are happy with the changes
  • add SYSTEM DROP S3 CLIENT CACHE

AWS SDK PR ClickHouse/aws-sdk-cpp#13

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

Information about CI checks: https://clickhouse.com/docs/en/development/continuous-integration/

@robot-ch-test-poll4 robot-ch-test-poll4 added pr-improvement Pull request with some product improvements submodule changed At least one submodule changed in this PR. labels Jan 30, 2023
@vitlibar vitlibar self-assigned this Feb 1, 2023
@antonio2368 antonio2368 marked this pull request as ready for review February 2, 2023 10:15
@nikitamikhaylov nikitamikhaylov merged commit d5117f2 into master Feb 3, 2023
@nikitamikhaylov nikitamikhaylov deleted the s3-with-head-bucket branch February 3, 2023 13:30
{
auto params = BaseRequest::GetEndpointContextParams();
if (!region_override.empty())
params.emplace_back("Region", region_override);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that BaseRequest::GetEndpointContextParams() already has this override?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be possible but the params are processed sequentially where the latest will always override the previous.
Also, the params are fetched from 3 different sources where BaseRequest::GetEndpointContextParams is the last one so we are sure it will always win. An assumption we need to be careful of if they change it in SDK (don't see a reason to do it but it could happen). I need to add a comment explaining exactly this.


ClientCache(const ClientCache & other)
: region_for_bucket_cache(other.region_for_bucket_cache)
, uri_for_bucket_cache(other.uri_for_bucket_cache)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to copy this cache?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but I didn't see a reason not to do it. We copy the client for globbed URLs and if it's copied than the endpoint is for sure the same so why not reuse the cache.

ClientCacheRegistry() = default;

std::mutex clients_mutex;
std::unordered_map<ClientCache *, std::weak_ptr<ClientCache>> client_caches;
Copy link
Member

@vitlibar vitlibar Feb 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to have this two-dimensional system? I mean can't we just keep region_for_bucket_cache and uri_for_bucket_cache here in this singleton class?

Copy link
Member Author

@antonio2368 antonio2368 Feb 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can but I wanted a way to detect if a client is deleted but the destructor failed to unregister it so we can clean the dead ptrs here if that happens.
If it was premature optimization I can simplify in the way as you described.

mutable std::optional<S3::URI> uri_override;
};

using HeadObjectRequest = ExtendedRequest<Model::HeadObjectRequest>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just modify BaseRequest without inheriting every S3 request type?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to avoid modifying the SDK because it's more complex to extend with new types (update fork then update the commit of the submodule). This way it's much easier to add support for new type of request IMO.

ClientCacheRegistry::instance().registerClient(cache);
}

/// Make regular functions private
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use requests from SDK the client can't override the region/endpoint so we make the function accepting Aws::S3::Model::*Request as private and the only way to call the functions with our client is with our extended requests.

, max_redirects(max_redirects_)
, log(&Poco::Logger::get("S3Client"))
{
auto * endpoint_provider = dynamic_cast<Aws::S3::Endpoint::S3DefaultEpProviderBase *>(accessEndpointProvider().get());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can endpoint_provider be nullptr?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it shouldn't be, AWS always does a nullptr check before accessing it, I can do chassert.

@antonio2368 antonio2368 mentioned this pull request Feb 6, 2023
1 task

template <typename RequestType, typename RequestFn>
std::invoke_result_t<RequestFn, RequestType>
doRequest(const RequestType & request, RequestFn request_fn) const
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This template function is not called anywhere except Client.cpp so it can be easily moved to Client.cpp.

detect_region = explicit_region == Aws::Region::AWS_GLOBAL && endpoint.find(".amazonaws.com") != std::string::npos;

cache = std::make_shared<ClientCache>();
ClientCacheRegistry::instance().registerClient(cache);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move the constructor to Client.cpp

if (auto region = getRegionForBucket(bucket); !region.empty())
{
if (!detect_region)
LOG_INFO(log, "Using region override {} for bucket {}", region, bucket);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the endpoint is specified with an explicit region then detect == false and we will always see this line in the logs. Looks excessive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only if we explicitly defined wrong region, if it was correct no log will be printed.
But yeah, it can still be excessive.

if (checkIfWrongRegionDefined(bucket, error, new_region))
{
request.overrideRegion(new_region);
return HeadObject(request);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HeadObject function doesn't have to be recursive here. If possible let's use just a cycle, usually they're easier for understanding and debugging.

{
auto uri_override = request.getURIOverride();
assert(uri_override.has_value());
updateURIForBucket(bucket, std::move(*uri_override));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is only one place where you set found_new_endpoint later - let's move this code there. SCOPE_EXIT makes the code harder to read.

request.overrideRegion(std::move(region));
}

if (auto uri = getURIForBucket(bucket); uri.has_value())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can add the function empty() to S3::URI and so make the code more consistent without using std::optional<S3::URI>.

return wrapped_strategy->RequestBookkeeping(httpResponseOutcome, lastError);
}

bool Client::checkIfWrongRegionDefined(const std::string & bucket, const Aws::S3::S3Error & error, std::string & region) const
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rename the function to checkIfExplicitRegionIsWrong for consistency with the variable explicit_region.

}
);

for (size_t attempt = 0; attempt <= max_redirects; ++attempt)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request_fn already has its own retries, see AWSClient::AttemptExhaustively(). And our code which uses S3::Client often has its own retries too. I'm afraid there can be too many retries.

Copy link
Member Author

@antonio2368 antonio2368 Feb 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already do something similar with PocoHTTPClient and with s3_max_redirects.
This is basically a redirect attempt + it should be much rarer with 1 or 2 jumps.

region = GetErrorMarshaller()->ExtractRegion(error);

if (region.empty())
region = getRegionForBucket(bucket, /*force_detect*/ true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we need to call HeadBucket after our request failed. In all known cases if a request fails because of the region the correct region would be in the response body. With one exception - HeadObject but we cover this case with HeadBucket before the main request.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the region is explicitly defined, the HeadBucket is not called.

auto bucket_uri = getURIForBucket(bucket);
if (!bucket_uri)
{
if (auto maybe_error = updateURIForBucketForHead(bucket); maybe_error.has_value())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should make our code that complicated. If HeadBucket and the HeadObject's response header both didn't help us to get a correct region then ListObjects will hardly help.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will help us if we got 301 but we couldn't get the location because HEAD doesn't have a response body.

@vitlibar
Copy link
Member

vitlibar commented Feb 6, 2023

I thought about this PR a while.

I hope that time when I updated AWS SDK in out contrib wasn't the last time we did it. And this PR introduced many changes which will make that updating quite hard. Inheriting AWS::S3::S3Client was ok, but inheriting all requests along copy-pasting a lot of changed code from AWS retry system don't feel right.

It seems all we needed were:

  1. Store regionFromResponse and newUri somewhere.
  2. Reuse region and uri from 1 for other requests later.
  3. Before sending HeadObject request we need to send HeadBucket first if it's an amazon endpoint and there isn't an explicit region in here.

For 1 we could add two virtual functions to AWSClient:

virtual void GotRegionFromResponse(const Aws::Http::URI & uri, const std::string & regionFromResponse) {}
virtual void GotURIFromResponse(const Aws::Http::URI & uri, const std::string & uriFromResponse) {}

then call them from AWSClient::AttemptExhaustively and override them in our S3::Client.

For 2 we could add two virtual functions to AWSClient:

virtual void MaybeOverrideURI(Aws::Http::URI & uri) {}
virtual void MaybeOverrideSigningRegion(std::string & signingRegion, const Aws::Http::URI & uri) {}

and again call them from AWSClient::AttemptExhaustively and override them in our S3::Client.

And for 3 we could call HeadBucket from our S3::Client::HeadObject once (without retries) and then proceed to normal AWSClient::HeadObject. And let AWS SDK do retries as it wants.

What's the difference? While updating AWS SDK it's much easier to resolve a few small conflicts because of we added a few empty functions than trying to understand what happened with that very complicated retry mechanism in our code or trying to figure out what we should do with new requests possibly added by AWS in the future - should we inherit them too or not?

@antonio2368
Copy link
Member Author

antonio2368 commented Feb 6, 2023

I can try doing it in this way, but I think we should do HeadBucket every time the region is not defined because for other requests there are no guarantees that the correct region will be returned, even for 301.

S3::Client::HeadObject is still a problem because URI redirects are in response body, something HEAD response doesn't have so that's why I did ListObject in the current implementation.
Trying to fit that into AttemptExhaustively could be tricky.

@vitlibar
Copy link
Member

vitlibar commented Feb 8, 2023

Do you know any example where some non-HeadObject request failed because of undefined region but the response didn't contain the correct region?

@antonio2368
Copy link
Member Author

Not sure now, I think I did see a request like that but why not cover this case just in case?
I was mostly using the Java client as a reference where they fetch bucket location on every request regardless of the defined region.

@Alima777
Copy link

Hi, @antonio2368. Is there any benchmark on this improved client?

Just like @vitlibar said, it's a great modification which may bring trouble when updating aws submodules. So I'd like to know how much improvement this client can bring?

@antonio2368
Copy link
Member Author

Hi @Alima777
there was no benchmark just for the client but the point of this PR was to make client more robust to different endpoint redirects and better region deduction.
When it comes to the performance, it was only important not to make it worse.

But there are many other client improvements already introduced and planned to be introduced in other PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-improvement Pull request with some product improvements submodule changed At least one submodule changed in this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants