From e16a5f7ff5248500cf0ff85c1d43ac5c29d72179 Mon Sep 17 00:00:00 2001 From: Dmitry Kropachev Date: Mon, 13 Jul 2026 15:35:00 -0400 Subject: [PATCH] DRIVER-434 Add DynamoDB header optimization --- CMakeLists.txt | 1 + README.md | 55 +++++++++- include/scylladb/alternator/config.h | 30 ++++++ src/aws_dynamodb_helper.cpp | 73 +++++++++++-- src/config.cpp | 28 +++++ tests/aws_dynamodb_helper_test.cpp | 155 +++++++++++++++++++++++++++ tests/header_optimization_test.cpp | 80 ++++++++++++++ 7 files changed, 414 insertions(+), 8 deletions(-) create mode 100644 tests/header_optimization_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 031fac7..a2da241 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -106,6 +106,7 @@ if(ALTERNATOR_CLIENT_CPP_BUILD_TESTS) set(ALTERNATOR_CLIENT_CPP_CORE_TEST_SOURCES tests/attribute_value_test.cpp + tests/header_optimization_test.cpp tests/http_client_test.cpp tests/key_route_affinity_test.cpp tests/live_nodes_integration_test.cpp diff --git a/README.md b/README.md index 28a5c10..ecc5f8e 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,60 @@ Alternator node discovery or load balancing. The AWS adapter uses the SDK's per-client `DynamoDBEndpointProviderBase` hook by default. AWS SDK for C++ resolves that endpoint once for a retried operation, so `DynamoDBHelper::ApplyToSDKOptions()` can also install a process-wide HTTP client factory before `Aws::InitAPI()`. That factory delegates to the SDK HTTP client and rotates only requests already aimed at the helper's Alternator endpoints. -The HTTP client factory runs after request signing. This is the closest public AWS SDK for C++ hook for retry-aware endpoint rewriting, but it means applications that depend on strict SigV4 host validation should prefer endpoint-provider routing or implement their own SDK wrapper. Automatic middleware features such as transparent header optimization and request-content-based endpoint rewriting are not available in the same form. The core library does provide deterministic key-route affinity primitives, and `DynamoDBHelper` exposes them for applications that integrate request-specific routing in their own AWS SDK wrapper. +The HTTP client factory runs after request signing. This is the closest public AWS SDK for C++ hook for retry-aware endpoint rewriting and header optimization, but it means applications that depend on strict SigV4 host validation should prefer endpoint-provider routing or implement their own SDK wrapper. Automatic request-content-based endpoint rewriting is not available in the same form. The core library does provide deterministic key-route affinity primitives, and `DynamoDBHelper` exposes them for applications that integrate request-specific routing in their own AWS SDK wrapper. + +### Headers Optimization + +Header optimization is disabled by default because `Config::header_optimization` +is null. Set it to a `HeaderOptimization` implementation to make +`DynamoDBHelper::ApplyToSDKOptions()` install an HTTP client factory that +removes DynamoDB request headers not used by Alternator before the request is +sent. The default allowlist keeps `Host`, `X-Amz-Target`, `Content-Length`, +`Accept-Encoding`, and `Content-Encoding`. When `Config::credentials` is set, +it also keeps `Authorization` and `X-Amz-Date`. When `Config::user_agent` is +set, it keeps `User-Agent`. + +```cpp +scylladb::alternator::Config cfg; +cfg.header_optimization = + std::make_shared(); +``` + +Use `HeaderAllowlistOptimization` to replace the default allowlist: + +```cpp +cfg.header_optimization = + std::make_shared( + std::vector{ + "Host", + "X-Amz-Target", + "Content-Length", + }); +``` + +Header names are matched case-insensitively. The override is exact; if it is +set, the helper does not add credential or user-agent headers automatically. +Applications can provide their own implementation by deriving from +`HeaderOptimization`: + +```cpp +class MyHeaderOptimization final : public scylladb::alternator::HeaderOptimization { +public: + std::vector AllowedHeaders( + const scylladb::alternator::HeaderOptimizationContext& context) const override { + std::vector headers = {"Host", "X-Amz-Target", "Content-Length"}; + if (context.credentials_configured) { + headers.push_back("Authorization"); + headers.push_back("X-Amz-Date"); + } + return headers; + } +}; +``` + +Header optimization applies only to requests routed to known Alternator +endpoints through the factory installed by `DynamoDBHelper::ApplyToSDKOptions()` +before `Aws::InitAPI()`. ### HTTP Response Compression diff --git a/include/scylladb/alternator/config.h b/include/scylladb/alternator/config.h index 6336eb0..b7e43e4 100644 --- a/include/scylladb/alternator/config.h +++ b/include/scylladb/alternator/config.h @@ -36,6 +36,35 @@ class ZlibContentEncodingDecoder final : public HttpContentEncodingDecoder { std::vector accepted_response_encodings_; }; +struct HeaderOptimizationContext { + bool credentials_configured = false; + bool user_agent_configured = false; +}; + +class HeaderOptimization { +public: + virtual ~HeaderOptimization() = default; + + [[nodiscard]] virtual std::vector AllowedHeaders( + const HeaderOptimizationContext& context) const = 0; +}; + +class DefaultHeaderOptimization final : public HeaderOptimization { +public: + [[nodiscard]] std::vector AllowedHeaders( + const HeaderOptimizationContext& context) const override; +}; + +class HeaderAllowlistOptimization final : public HeaderOptimization { +public: + explicit HeaderAllowlistOptimization(std::vector allowed_headers); + + [[nodiscard]] std::vector AllowedHeaders( + const HeaderOptimizationContext& context) const override; +private: + std::vector allowed_headers_; +}; + struct Config { std::uint16_t port = 8080; std::string scheme = "http"; @@ -60,6 +89,7 @@ struct Config { bool reuse_discovery_connections = true; std::vector> content_encoding_decoders; std::string user_agent = "scylladb-alternator-client-cpp/devel"; + std::shared_ptr header_optimization; NodeHealthStoreConfig node_health; KeyRouteAffinityConfig key_route_affinity; diff --git a/src/aws_dynamodb_helper.cpp b/src/aws_dynamodb_helper.cpp index 9ce0d15..3c8c777 100644 --- a/src/aws_dynamodb_helper.cpp +++ b/src/aws_dynamodb_helper.cpp @@ -43,6 +43,11 @@ namespace { constexpr char kAllocationTag[] = "ScyllaDBAlternatorAwsAdapter"; constexpr char kEndpointOverrideHost[] = "dynamodb.fake.alternator.cluster.node"; +struct HeaderOptimizationPolicy { + bool enabled = false; + std::set allowed_headers; +}; + Aws::Http::Scheme AwsScheme(const std::string& scheme) { return scheme == "https" ? Aws::Http::Scheme::HTTPS : Aws::Http::Scheme::HTTP; } @@ -184,6 +189,46 @@ bool IsFirstSdkAttempt(const Aws::Http::HttpRequest& request) { return value_pos < header.size() && header[value_pos] == '1'; } +std::set NormalizeHeaderAllowlist(const std::vector& headers) { + std::set allowlist; + for (const auto& header : headers) { + allowlist.insert(detail::ToLowerAscii(header)); + } + return allowlist; +} + +HeaderOptimizationContext HeaderOptimizationContextFromConfig(const Config& config) { + return { + !config.credentials.access_key_id.empty() || !config.credentials.secret_access_key.empty(), + !config.user_agent.empty(), + }; +} + +HeaderOptimizationPolicy BuildHeaderOptimizationPolicy(const Config& config) { + if (!config.header_optimization) { + return {}; + } + + const auto headers = config.header_optimization->AllowedHeaders( + HeaderOptimizationContextFromConfig(config)); + return {true, NormalizeHeaderAllowlist(headers)}; +} + +void OptimizeRequestHeaders(const std::shared_ptr& request, + const HeaderOptimizationPolicy& policy) { + if (!request || !policy.enabled) { + return; + } + + const auto headers = request->GetHeaders(); + for (const auto& header : headers) { + const auto header_name = detail::ToLowerAscii(std::string(header.first.c_str())); + if (policy.allowed_headers.find(header_name) == policy.allowed_headers.end()) { + request->DeleteHeader(header.first.c_str()); + } + } +} + Url SelectAttemptNode(const std::shared_ptr& request, const std::shared_ptr& nodes, std::uint16_t endpoint_override_port) { @@ -208,11 +253,13 @@ class AlternatorHttpClient final : public Aws::Http::HttpClient { AlternatorHttpClient(std::shared_ptr nodes, std::uint16_t endpoint_override_port, std::vector> content_encoding_decoders, + HeaderOptimizationPolicy header_optimization, std::shared_ptr delegate) : nodes_(std::move(nodes)) , endpoint_override_port_(endpoint_override_port) , content_encoding_decoders_(std::move(content_encoding_decoders)) , accept_encoding_value_(detail::BuildAcceptEncodingValue(content_encoding_decoders_)) + , header_optimization_(std::move(header_optimization)) , delegate_(std::move(delegate)) { if (!nodes_) { throw std::invalid_argument("nodes must not be null"); @@ -230,6 +277,9 @@ class AlternatorHttpClient final : public Aws::Http::HttpClient { if (!node.Empty() && !accept_encoding_value_.empty()) { request->SetAcceptEncoding(Aws::String(accept_encoding_value_.c_str())); } + if (!node.Empty()) { + OptimizeRequestHeaders(request, header_optimization_); + } auto response = delegate_->MakeRequest(request, read_limiter, write_limiter); if (!node.Empty()) { @@ -253,6 +303,7 @@ class AlternatorHttpClient final : public Aws::Http::HttpClient { std::uint16_t endpoint_override_port_ = 0; std::vector> content_encoding_decoders_; std::string accept_encoding_value_; + HeaderOptimizationPolicy header_optimization_; std::shared_ptr delegate_; }; @@ -260,10 +311,12 @@ class AlternatorHttpClientFactory final : public Aws::Http::HttpClientFactory { public: AlternatorHttpClientFactory(std::shared_ptr nodes, std::uint16_t endpoint_override_port, - std::vector> content_encoding_decoders) + std::vector> content_encoding_decoders, + HeaderOptimizationPolicy header_optimization) : nodes_(std::move(nodes)) , endpoint_override_port_(endpoint_override_port) - , content_encoding_decoders_(std::move(content_encoding_decoders)) { + , content_encoding_decoders_(std::move(content_encoding_decoders)) + , header_optimization_(std::move(header_optimization)) { if (!nodes_) { throw std::invalid_argument("nodes must not be null"); } @@ -281,6 +334,7 @@ class AlternatorHttpClientFactory final : public Aws::Http::HttpClientFactory { nodes_, endpoint_override_port_, content_encoding_decoders_, + header_optimization_, std::move(delegate)); } @@ -315,6 +369,7 @@ class AlternatorHttpClientFactory final : public Aws::Http::HttpClientFactory { std::shared_ptr nodes_; std::uint16_t endpoint_override_port_ = 0; std::vector> content_encoding_decoders_; + HeaderOptimizationPolicy header_optimization_; }; std::string EndpointOverride(std::uint16_t port, const std::string& scheme) { @@ -324,12 +379,14 @@ std::string EndpointOverride(std::uint16_t port, const std::string& scheme) { std::shared_ptr NewAlternatorHttpClientFactory( std::shared_ptr nodes, std::uint16_t endpoint_override_port, - std::vector> content_encoding_decoders) { + std::vector> content_encoding_decoders, + HeaderOptimizationPolicy header_optimization) { return Aws::MakeShared( kAllocationTag, std::move(nodes), endpoint_override_port, - std::move(content_encoding_decoders)); + std::move(content_encoding_decoders), + std::move(header_optimization)); } class FixedEndpointProvider final : public Aws::DynamoDB::Endpoint::DynamoDBEndpointProviderBase { @@ -741,7 +798,8 @@ std::shared_ptr DynamoDBHelper::NewHttpClientFacto return NewAlternatorHttpClientFactory( nodes_, config_.port, - config_.content_encoding_decoders); + config_.content_encoding_decoders, + BuildHeaderOptimizationPolicy(config_)); } Aws::DynamoDB::DynamoDBClientConfiguration DynamoDBHelper::NewClientConfiguration() const { @@ -772,8 +830,9 @@ void DynamoDBHelper::ApplyToSDKOptions(Aws::SDKOptions& options) const { auto nodes = nodes_; const auto port = config_.port; const auto content_encoding_decoders = config_.content_encoding_decoders; - options.httpOptions.httpClientFactory_create_fn = [nodes, port, content_encoding_decoders] { - return NewAlternatorHttpClientFactory(nodes, port, content_encoding_decoders); + const auto header_optimization = BuildHeaderOptimizationPolicy(config_); + options.httpOptions.httpClientFactory_create_fn = [nodes, port, content_encoding_decoders, header_optimization] { + return NewAlternatorHttpClientFactory(nodes, port, content_encoding_decoders, header_optimization); }; } diff --git a/src/config.cpp b/src/config.cpp index 3a45524..8cd31a2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -3,9 +3,37 @@ #include "http_compression.h" #include +#include namespace scylladb::alternator { +std::vector DefaultHeaderOptimization::AllowedHeaders( + const HeaderOptimizationContext& context) const { + std::vector headers = { + "Host", + "X-Amz-Target", + "Content-Length", + "Accept-Encoding", + "Content-Encoding", + }; + if (context.credentials_configured) { + headers.push_back("Authorization"); + headers.push_back("X-Amz-Date"); + } + if (context.user_agent_configured) { + headers.push_back("User-Agent"); + } + return headers; +} + +HeaderAllowlistOptimization::HeaderAllowlistOptimization(std::vector allowed_headers) + : allowed_headers_(std::move(allowed_headers)) {} + +std::vector HeaderAllowlistOptimization::AllowedHeaders( + const HeaderOptimizationContext&) const { + return allowed_headers_; +} + void ValidateConfig(const Config& config) { if (config.scheme != "http" && config.scheme != "https") { throw std::invalid_argument("scheme must be http or https"); diff --git a/tests/aws_dynamodb_helper_test.cpp b/tests/aws_dynamodb_helper_test.cpp index 26ae6d9..6fc53e3 100644 --- a/tests/aws_dynamodb_helper_test.cpp +++ b/tests/aws_dynamodb_helper_test.cpp @@ -29,6 +29,7 @@ #if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB #include #endif +#include #include #include #include @@ -346,6 +347,33 @@ std::string ToLowerAscii(std::string value) { return value; } +std::map RequestHeaders(const std::string& request) { + std::map headers; + const auto header_end = request.find("\r\n\r\n"); + if (header_end == std::string::npos) { + return headers; + } + + std::istringstream lines(request.substr(0, header_end)); + std::string line; + std::getline(lines, line); + while (std::getline(lines, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + const auto colon = line.find(':'); + if (colon == std::string::npos) { + continue; + } + auto value = line.substr(colon + 1); + while (!value.empty() && std::isspace(static_cast(value.front()))) { + value.erase(value.begin()); + } + headers.emplace(ToLowerAscii(line.substr(0, colon)), std::move(value)); + } + return headers; +} + #if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB std::string CompressBody(const std::string& body, int window_bits) { if (body.size() > std::numeric_limits::max()) { @@ -777,6 +805,133 @@ TEST(AwsDynamoDBHelper, HttpClientFactoryRotatesNodesAcrossRetries) { EXPECT_NE(first_host, second_host); } +TEST(AwsDynamoDBHelper, HttpClientFactoryOptimizesSignedRequestHeaders) { + KeepAliveSequenceHttpServer server({ + {200, "OK", R"({"TableNames":[]})"}, + }); + + auto cfg = DiscoveryTestConfig(server.Port()); + cfg.header_optimization = std::make_shared(); + aws::DynamoDBHelper helper({"127.0.0.1"}, cfg); + + Aws::SDKOptions sdk_options; + helper.ApplyToSDKOptions(sdk_options); + AwsApiGuard api(sdk_options); + + auto client_config = helper.NewClientConfiguration(); + client_config.retryStrategy = Aws::MakeShared( + "AlternatorClientCppHeaderOptimizationTestRetryStrategy", + 0, + 0); + client_config.version = Aws::Http::Version::HTTP_VERSION_1_1; + + Aws::Auth::AWSCredentials credentials("alternator", "secret"); + Aws::DynamoDB::DynamoDBClient client(credentials, helper.NewEndpointProvider(), client_config); + + Aws::DynamoDB::Model::ListTablesRequest request; + auto outcome = client.ListTables(request); + EXPECT_TRUE(outcome.IsSuccess()) << outcome.GetError().GetMessage(); + + server.Wait(); + + ASSERT_EQ(server.Requests().size(), 1U); + const auto headers = RequestHeaders(server.Requests()[0]); + EXPECT_NE(headers.find("host"), headers.end()); + EXPECT_NE(headers.find("content-length"), headers.end()); + EXPECT_NE(headers.find("x-amz-target"), headers.end()); + EXPECT_NE(headers.find("authorization"), headers.end()); + EXPECT_NE(headers.find("x-amz-date"), headers.end()); + EXPECT_NE(headers.find("user-agent"), headers.end()); + EXPECT_EQ(headers.find("content-type"), headers.end()); + EXPECT_EQ(headers.find("amz-sdk-invocation-id"), headers.end()); + EXPECT_EQ(headers.find("amz-sdk-request"), headers.end()); + EXPECT_EQ(headers.find("x-amz-content-sha256"), headers.end()); +} + +TEST(AwsDynamoDBHelper, HttpClientFactoryOmitsAuthHeadersWhenCredentialsAreNotConfigured) { + KeepAliveSequenceHttpServer server({ + {200, "OK", R"({"TableNames":[]})"}, + }); + + auto cfg = DiscoveryTestConfig(server.Port()); + cfg.credentials = {}; + cfg.header_optimization = std::make_shared(); + aws::DynamoDBHelper helper({"127.0.0.1"}, cfg); + + Aws::SDKOptions sdk_options; + helper.ApplyToSDKOptions(sdk_options); + AwsApiGuard api(sdk_options); + + auto client_config = helper.NewClientConfiguration(); + client_config.retryStrategy = Aws::MakeShared( + "AlternatorClientCppHeaderOptimizationNoConfiguredCredentialsTestRetryStrategy", + 0, + 0); + client_config.version = Aws::Http::Version::HTTP_VERSION_1_1; + + Aws::Auth::AWSCredentials credentials("external", "secret"); + Aws::DynamoDB::DynamoDBClient client(credentials, helper.NewEndpointProvider(), client_config); + + Aws::DynamoDB::Model::ListTablesRequest request; + auto outcome = client.ListTables(request); + EXPECT_TRUE(outcome.IsSuccess()) << outcome.GetError().GetMessage(); + + server.Wait(); + + ASSERT_EQ(server.Requests().size(), 1U); + const auto headers = RequestHeaders(server.Requests()[0]); + EXPECT_NE(headers.find("host"), headers.end()); + EXPECT_NE(headers.find("content-length"), headers.end()); + EXPECT_NE(headers.find("x-amz-target"), headers.end()); + EXPECT_EQ(headers.find("authorization"), headers.end()); + EXPECT_EQ(headers.find("x-amz-date"), headers.end()); + EXPECT_NE(headers.find("user-agent"), headers.end()); +} + +TEST(AwsDynamoDBHelper, HttpClientFactoryUsesCustomOptimizedHeaderAllowlist) { + KeepAliveSequenceHttpServer server({ + {200, "OK", R"({"TableNames":[]})"}, + }); + + auto cfg = DiscoveryTestConfig(server.Port()); + cfg.header_optimization = std::make_shared(std::vector{ + "Host", + "X-Amz-Target", + "Content-Length", + }); + aws::DynamoDBHelper helper({"127.0.0.1"}, cfg); + + Aws::SDKOptions sdk_options; + helper.ApplyToSDKOptions(sdk_options); + AwsApiGuard api(sdk_options); + + auto client_config = helper.NewClientConfiguration(); + client_config.retryStrategy = Aws::MakeShared( + "AlternatorClientCppCustomHeaderOptimizationTestRetryStrategy", + 0, + 0); + client_config.version = Aws::Http::Version::HTTP_VERSION_1_1; + + Aws::Auth::AWSCredentials credentials("alternator", "secret"); + Aws::DynamoDB::DynamoDBClient client(credentials, helper.NewEndpointProvider(), client_config); + + Aws::DynamoDB::Model::ListTablesRequest request; + auto outcome = client.ListTables(request); + EXPECT_TRUE(outcome.IsSuccess()) << outcome.GetError().GetMessage(); + + server.Wait(); + + ASSERT_EQ(server.Requests().size(), 1U); + const auto headers = RequestHeaders(server.Requests()[0]); + EXPECT_NE(headers.find("host"), headers.end()); + EXPECT_NE(headers.find("content-length"), headers.end()); + EXPECT_NE(headers.find("x-amz-target"), headers.end()); + EXPECT_EQ(headers.find("authorization"), headers.end()); + EXPECT_EQ(headers.find("x-amz-date"), headers.end()); + EXPECT_EQ(headers.find("user-agent"), headers.end()); + EXPECT_EQ(headers.find("content-type"), headers.end()); +} + TEST(AwsDynamoDBHelper, HttpClientFactoryDecodesGzipResponses) { #if SCYLLADB_ALTERNATOR_CLIENT_CPP_HAS_ZLIB KeepAliveSequenceHttpServer server({ diff --git a/tests/header_optimization_test.cpp b/tests/header_optimization_test.cpp new file mode 100644 index 0000000..b8eb056 --- /dev/null +++ b/tests/header_optimization_test.cpp @@ -0,0 +1,80 @@ +#include + +#include + +#include +#include + +using namespace scylladb::alternator; + +namespace { + +class TestHeaderOptimization final : public HeaderOptimization { +public: + std::vector AllowedHeaders(const HeaderOptimizationContext& context) const override { + std::vector headers = {"Host"}; + if (context.credentials_configured) { + headers.push_back("Authorization"); + } + if (context.user_agent_configured) { + headers.push_back("User-Agent"); + } + return headers; + } +}; + +} // namespace + +TEST(HeaderOptimization, DefaultAllowlistUsesContext) { + const DefaultHeaderOptimization optimization; + + EXPECT_EQ( + optimization.AllowedHeaders({false, false}), + std::vector({ + "Host", + "X-Amz-Target", + "Content-Length", + "Accept-Encoding", + "Content-Encoding", + })); + EXPECT_EQ( + optimization.AllowedHeaders({true, true}), + std::vector({ + "Host", + "X-Amz-Target", + "Content-Length", + "Accept-Encoding", + "Content-Encoding", + "Authorization", + "X-Amz-Date", + "User-Agent", + })); +} + +TEST(HeaderOptimization, FixedAllowlistIgnoresContext) { + const HeaderAllowlistOptimization optimization({"Host", "X-Amz-Target"}); + + EXPECT_EQ( + optimization.AllowedHeaders({true, true}), + std::vector({ + "Host", + "X-Amz-Target", + })); +} + +TEST(HeaderOptimization, SupportsCustomImplementation) { + const TestHeaderOptimization optimization; + + EXPECT_EQ( + optimization.AllowedHeaders({true, false}), + std::vector({ + "Host", + "Authorization", + })); + EXPECT_EQ( + optimization.AllowedHeaders({false, true}), + std::vector({ + "Host", + "User-Agent", + })); +}