diff --git a/bazel/foreign_cc/BUILD b/bazel/foreign_cc/BUILD index c87f82ff4eae2..f0cc86f26be52 100644 --- a/bazel/foreign_cc/BUILD +++ b/bazel/foreign_cc/BUILD @@ -189,6 +189,21 @@ envoy_cmake_external( }), ) +envoy_cmake_external( + name = "hazelcast_cpp_client", + cache_entries = { + "HZ_LIB_TYPE": "STATIC", + "HZ_BIT": "64", + "CMAKE_BUILD_TYPE": "RELEASE", + }, + defines = ["HAZELCAST_USE_STATIC"], + lib_source = "@com_github_hazelcast_cpp_client//:all", + static_libraries = select({ + "//bazel:windows_x86_64": ["HazelcastClient3.12.1_64.lib"], + "//conditions:default": ["libHazelcastClient3.12.1_64.a"], + }), +) + envoy_cmake_external( name = "nghttp2", cache_entries = { diff --git a/bazel/foreign_cc/hazelcast_cpp_client.patch b/bazel/foreign_cc/hazelcast_cpp_client.patch new file mode 100644 index 0000000000000..cbea3a2fb607d --- /dev/null +++ b/bazel/foreign_cc/hazelcast_cpp_client.patch @@ -0,0 +1,188 @@ +--- CMakeLists.txt 2020-02-27 11:35:35.000000000 +0300 ++++ CMakeLists.txt 2020-02-27 11:37:15.000000000 +0300 +@@ -14,6 +14,7 @@ + # limitations under the License. + # + cmake_minimum_required (VERSION 2.6.4) ++set(CMAKE_CXX_COMPILER_WORKS 1) + project (HazelcastClient) + + # FLAGS +@@ -279,3 +280,9 @@ + ADD_SUBDIRECTORY(examples) + message(STATUS "Configured to build the examples.") + ENDIF(HZ_BUILD_EXAMPLES) ++INSTALL(TARGETS ${HZ_LIB_NAME} DESTINATION lib) ++INSTALL(DIRECTORY ${PROJECT_SOURCE_DIR}/hazelcast/generated-sources/include/hazelcast DESTINATION include FILES_MATCHING PATTERN "*.h") ++INSTALL(DIRECTORY ${PROJECT_SOURCE_DIR}/hazelcast/include/hazelcast DESTINATION include FILES_MATCHING PATTERN "*.h" PATTERN "*.inl") ++INSTALL(DIRECTORY ${PROJECT_SOURCE_DIR}/external/release_include/boost DESTINATION include FILES_MATCHING PATTERN "*.hpp" PATTERN "*.h") ++INSTALL(DIRECTORY ${PROJECT_SOURCE_DIR}/external/release_include/easylogging++ DESTINATION include FILES_MATCHING PATTERN "*.h") ++INSTALL(DIRECTORY ${PROJECT_SOURCE_DIR}/external/include/asio/asio/include/asio DESTINATION include FILES_MATCHING PATTERN "*.hpp") + +--- hazelcast/include/hazelcast/util/Bits.h 2020-01-28 11:42:05.000000000 +0300 ++++ hazelcast/include/hazelcast/util/Bits.h 2020-05-22 23:11:16.000000000 +0300 +@@ -26,6 +26,7 @@ + #endif + + #include ++#include // This patch fixes asan build for Envoy Http Cache. + + #if defined(linux) || defined(__linux__) || defined (__GLIBC__) || defined(__GNU__) + +@@ -124,7 +125,7 @@ + #ifdef HZ_BIG_ENDIAN + swap_2(source, target); + #else +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint16_t)); + #endif + } + +@@ -136,7 +137,7 @@ + #ifdef HZ_BIG_ENDIAN + swap_4(source, target); + #else +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint32_t)); + #endif + } + +@@ -148,7 +149,7 @@ + #ifdef HZ_BIG_ENDIAN + swap_8(source, target); + #else +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint64_t)); + #endif + } + +@@ -170,7 +171,7 @@ + #ifdef HZ_BIG_ENDIAN + swap_2(source, target); + #else +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint16_t)); + #endif + } + +@@ -182,7 +183,7 @@ + #ifdef HZ_BIG_ENDIAN + swap_4(source, target); + #else +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint32_t)); + #endif + } + +@@ -194,13 +195,15 @@ + #ifdef HZ_BIG_ENDIAN + swap_8(source, target); + #else +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint64_t)); + #endif + } + + inline static int32_t readIntB(std::vector &buffer, unsigned long pos) { + #ifdef HZ_BIG_ENDIAN +- return *((int32_t *) (&buffer[0] + pos)); ++ int32_t result; ++ memcpy(&result, (&buffer[0] + pos), sizeof(int32_t)); ++ return result; + #else + int32_t result; + swap_4(&(buffer[0]) + pos, &result); +@@ -215,7 +218,7 @@ + */ + inline static void bigEndianToNative2(const void *source, void *target) { + #ifdef HZ_BIG_ENDIAN +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint16_t)); + #else + swap_2(source, target); + #endif +@@ -227,7 +230,7 @@ + */ + inline static void bigEndianToNative4(const void *source, void *target) { + #ifdef HZ_BIG_ENDIAN +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint32_t)); + #else + swap_4(source, target); + #endif +@@ -239,7 +242,7 @@ + */ + inline static void bigEndianToNative8(const void *source, void *target) { + #ifdef HZ_BIG_ENDIAN +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint64_t)); + #else + swap_8(source, target); + #endif +@@ -251,7 +254,7 @@ + */ + inline static void nativeToBigEndian2(void *source, void *target) { + #ifdef HZ_BIG_ENDIAN +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint16_t)); + #else + swap_2(source, target); + +@@ -264,7 +267,7 @@ + */ + inline static void nativeToBigEndian4(const void *source, void *target) { + #ifdef HZ_BIG_ENDIAN +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint32_t)); + #else + swap_4(source, target); + #endif +@@ -276,7 +279,7 @@ + */ + inline static void nativeToBigEndian8(void *source, void *target) { + #ifdef HZ_BIG_ENDIAN +- *(static_cast(target)) = *(static_cast(source)); ++ memcpy(target, source, sizeof(uint64_t)); + #else + swap_8(source, target); + #endif +@@ -286,25 +289,32 @@ + + private : + inline static void swap_2(const void *orig, void* target) { +- *reinterpret_cast (target) = +- bswap16 (*reinterpret_cast (orig)); ++ uint16_t raw; ++ memcpy(&raw, orig, sizeof(uint16_t)); ++ uint16_t swapped = bswap16(raw); ++ memcpy(target, &swapped, sizeof(uint16_t)); + } + + inline static void swapInplace4(void *orig) { +- uint32_t value = * reinterpret_cast (orig); ++ uint32_t value; ++ memcpy(&value, orig, sizeof(uint32_t)); + swap_4(&value, orig); + } + + inline static void swap_4 (const void* orig, void* target) + { +- *reinterpret_cast (target) = +- bswap32 (*reinterpret_cast (orig)); ++ uint32_t raw; ++ memcpy(&raw, orig, sizeof(uint32_t)); ++ uint32_t swapped = bswap32(raw); ++ memcpy(target, &swapped, sizeof(uint32_t)); + } + + inline static void swap_8 (const void* orig, void* target) + { +- *reinterpret_cast (target) = +- bswap64 (*reinterpret_cast (orig)); ++ uint64_t raw; ++ memcpy(&raw, orig, sizeof(uint64_t)); ++ uint64_t swapped = bswap64(raw); ++ memcpy(target, &swapped, sizeof(uint64_t)); + } + }; + } diff --git a/bazel/repositories.bzl b/bazel/repositories.bzl index 6d104839e91ac..b5e18b2b4a375 100644 --- a/bazel/repositories.bzl +++ b/bazel/repositories.bzl @@ -166,6 +166,7 @@ def envoy_dependencies(skip_targets = []): _com_github_google_libprotobuf_mutator() _com_github_gperftools_gperftools() _com_github_grpc_grpc() + _com_github_hazelcast_cpp_client() _com_github_jbeder_yaml_cpp() _com_github_libevent_libevent() _com_github_luajit_luajit() @@ -327,6 +328,20 @@ def _com_github_google_libprotobuf_mutator(): build_file = "@envoy//bazel/external:libprotobuf_mutator.BUILD", ) +def _com_github_hazelcast_cpp_client(): + location = _get_location("com_github_hazelcast_cpp_client") + http_archive( + name = "com_github_hazelcast_cpp_client", + build_file_content = BUILD_ALL_CONTENT, + patch_args = ["-p0"], + patches = ["@envoy//bazel/foreign_cc:hazelcast_cpp_client.patch"], + **location + ) + native.bind( + name = "hazelcast_cpp_client", + actual = "@envoy//bazel/foreign_cc:hazelcast_cpp_client", + ) + def _com_github_jbeder_yaml_cpp(): location = _get_location("com_github_jbeder_yaml_cpp") http_archive( diff --git a/bazel/repository_locations.bzl b/bazel/repository_locations.bzl index f636e983d8afc..847c70cc5ecc1 100644 --- a/bazel/repository_locations.bzl +++ b/bazel/repository_locations.bzl @@ -186,6 +186,15 @@ DEPENDENCY_REPOSITORIES = dict( use_category = ["dataplane", "controlplane"], cpe = "cpe:2.3:a:grpc:grpc:*", ), + com_github_hazelcast_cpp_client = dict( + sha256 = "3c43c81135e415ce708486564dc125bde93c2c9f8965d5af4b603ec91ff52f6e", + strip_prefix = "hazelcast-cpp-client-3.12.1", + # Using non official tarball due to missing submodule files in the official release. + # TODO(enozcan): Use official release with init & updating submodules + urls = ["https://github.com/enozcan/envoy-hazelcast-cpp-client/raw/master/hazelcast-cpp-client-3.12.1.zip"], + use_category = ["dataplane"], + cpe = "N/A", + ), com_github_luajit_luajit = dict( sha256 = "409f7fe570d3c16558e594421c47bdd130238323c9d6fd6c83dedd2aaeb082a8", strip_prefix = "LuaJIT-2.1.0-beta3", diff --git a/source/common/common/logger.h b/source/common/common/logger.h index 3b2fd61db5bff..a0b56ada30918 100644 --- a/source/common/common/logger.h +++ b/source/common/common/logger.h @@ -40,6 +40,7 @@ namespace Logger { FUNCTION(filter) \ FUNCTION(forward_proxy) \ FUNCTION(grpc) \ + FUNCTION(hazelcast_http_cache) \ FUNCTION(hc) \ FUNCTION(health_checker) \ FUNCTION(http) \ diff --git a/source/docs/filters/http/cache/hazelcast_cache_plugin.md b/source/docs/filters/http/cache/hazelcast_cache_plugin.md new file mode 100644 index 0000000000000..61d5633ca80de --- /dev/null +++ b/source/docs/filters/http/cache/hazelcast_cache_plugin.md @@ -0,0 +1,194 @@ +### Hazelcast Http Cache Plugin +Work in Progress--Cache filter has not implemented features. The corresponding ones are not ready for the plugin too. + +Hazelcast Http Cache provides a pluggable storage implementation backed by Hazelcast In Memory Data Grid for the Http +cache filter. Using Hazelcast C++ client, the plugin does not store any Http response locally but in a distributed map +provided by Hazelcast cluster. After having a Hazelcast cluster up and running, passing the network address of a +cluster member to the cache plugin via configuration will be enough for client to connect to the cluster. + +## Offered cache modes +The plugin comes with two modes: + + - **Unified** +A cached Http response is stored as a single entry in the cache. On a range Http request, regardless of the requested +range, the whole response body is fetched from the cache and then only the desired bytes are served along with the +headers and trailers (if any). This mode is handy where response body sizes are relatively small (up to 32 KB), or +range requests are not frequent, or they are not allowed at all. + + - **Divided** +A cached Http response is stored as multiple entries in the cache. Two separate maps are used to store a single +response. In one of them, response headers, body size, and trailers (if any) are stored. In the other one, the +corresponding response body is stored in multiple entries each of which has a certain size configured via `partition +size` in the plugin configuration. That is, for a response of size 50 KB, if the configured partition size is 20 KB, +then three different entries will be created to store the body of this response: + - Body<1> : 0 - 19 KB + - Body<2> : 20 - 39 KB + - Body<3> : 40 - 50 KB + + On a range request, not the whole body for a response but only the necessary partitions are fetched from the + cache. This option helps to serve range requests faster and in a stream-like fashion but comes with a cost. Every + body entry has its own fixed memory cost and hence partitioned entries need larger memory than the actual body + size. Also, to keep these partitioned cache entries even, extra operations - not necessarily asynchronous, might + be needed (i.e. cleaning up a malformed body sequence, recovery from a mismatch between body and header, etc.). + +Maximum body size limit must be configured in CacheConfig. In UNIFIED mode, the maximum allowed body size is 32 KB. +Any value above this will be ignored and 32 KB will be used as the limit. If an insertion for a larger value than +maximum is attempted by the cache filter, only the first 32 KB of the response body will be cached. + +In DIVIDED mode, there is no such an upper limit but keeping (max_body_size / body_partition_size) below 20 is +recommended since each partition causes an extra network call made to the distributed map. + +## Connecting to a Hazelcast cluster +**NOTE:** The plugin uses the client with version 3.12.1 and hence it is not yet compatible with Hazelcast 4.x. +Hazelcast version 3.12.x is recommended for the server-side. + +Before starting the cache plugin, there must be a running Hazelcast cluster. Hazelcast instances might be started +as a sidecar to Envoy, form up a cluster using Hazelcast Kubernetes plugin, etc. The only information the plugin needs +will be the addresses and ports of the cluster members and the group information of the cluster. Providing the address +of only one member in the cluster will be enough for the connection but using more than one is recommended. + +Related links: [Hazelcast Docker Hub](https://hub.docker.com/r/hazelcast/hazelcast/), +[Hazelcast Kubernetes Plugin](https://github.com/hazelcast/hazelcast-kubernetes) + +## Configuring Hazelcast cluster for the cache +Eviction, maximum size, and other related properties for the cache must be configured on the server-side +via programmatic configuration or `hazelcast.xml`. + + - **Unified Mode** + +```xml + + + + + 1000 + 25 + LRU + 180 + 90 + + + BINARY + true + +``` + + - **Divided Mode** + +```xml + + + + + 100 + 25 + LRU + 180 + + + BINARY + true + + + + + + 0 + 195 + + + BINARY + false + +``` + +When one of the clients connected to the cluster loses its connection, if the client has acquired the lock for a +key, this will cause this key to be unusable. To prevent such a scenario in a possible connection failure, the +maximum time limit for the locks should be set on the server-side (not necessarily to be 60 seconds). The default +value for this property is `Long.MAX`. Hence, if it is not set, on a connection failure a locked key will +be unusable permanently: +```xml + + ... + + 60 + ... + +``` +**NOTE**: Setting this property will affect not only the Http cache but all other data structures in the cluster. + +## Statistics +Cache statistics are not collected locally. Instead, cluster-wide statistics should be observed on Hazelcast +Management Center. When the cache plugin starts, one of the very first logs will be saying the map name used for +the cache. The statistics can be observed with that name under the `maps` section on the management center. + +## Using a single cache for multiple filters +Each distributed map in a Hazelcast cluster is differentiated by its name for the same key and value types. Thus, +all the plugins connected to the same cluster will use the same map for responses only if they have the same cache +mode and the app prefix (and the same partition size for divided mode) in the plugin configuration. The filters +configured with the same partition size and cache mode but different prefixes will create two different Http caches. \ No newline at end of file diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index 348862eea1e83..3ac84bc25ae7c 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -181,6 +181,7 @@ EXTENSIONS = { # CacheFilter plugins # + "envoy.filters.http.cache.hazelcast_http_cache": "//source/extensions/filters/http/cache/hazelcast_http_cache:hazelcast_http_cache_lib", "envoy.filters.http.cache.simple_http_cache": "//source/extensions/filters/http/cache/simple_http_cache:simple_http_cache_lib", # diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/BUILD b/source/extensions/filters/http/cache/hazelcast_http_cache/BUILD new file mode 100644 index 0000000000000..8552fa77d814d --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/BUILD @@ -0,0 +1,45 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_package", + "envoy_proto_library", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_cc_extension( + name = "hazelcast_http_cache_lib", + srcs = [ + "hazelcast_cache_entry.cc", + "hazelcast_context.cc", + "hazelcast_http_cache.cc", + "hazelcast_storage_accessor.cc", + ], + hdrs = [ + "hazelcast_cache_entry.h", + "hazelcast_context.h", + "hazelcast_http_cache.h", + "hazelcast_storage_accessor.h", + "util.h", + ], + external_deps = ["hazelcast_cpp_client"], + security_posture = "robust_to_untrusted_downstream_and_upstream", + status = "wip", + deps = [ + ":config_cc_proto", + "//include/envoy/registry", + "//source/common/buffer:buffer_lib", + "//source/common/common:logger_lib", + "//source/common/http:header_map_lib", + "//source/common/runtime:runtime_lib", + "//source/extensions/filters/http/cache:http_cache_lib", + ], +) + +envoy_proto_library( + name = "config", + srcs = ["config.proto"], + deps = ["@envoy_api//envoy/config/core/v3:pkg"], +) diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/config.proto b/source/extensions/filters/http/cache/hazelcast_http_cache/config.proto new file mode 100644 index 0000000000000..a8a92bc5d0a20 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/config.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package envoy.source.extensions.filters.http.cache; + +import "envoy/config/core/v3/address.proto"; + +// [#protodoc-title: HazelcastHttpCache CacheFilter storage plugin] +// CacheFilter plugin backed by Hazelcast In Memory Data Grid. +// [#extension: envoy.extensions.http.cache] + +// Hazelcast Http Cache configuration +message HazelcastHttpCacheConfig { + + // Group name of the Hazelcast cluster to be connected. + string group_name = 1; + // Group password of the Hazelcast cluster to be connected. + string group_password = 2; + + // The timeout value in milliseconds for Hazelcast members to accept this client's + // connection requests. If the member does not respond within the timeout, the client + // will think the connection is lost and retry to connect to cluster as many as + // connection_attempt_limit. 5000 by default and 0 is not allowed. + uint32 connection_timeout = 3; + + // When the client connection to the cluster is down, client will retry as many + // as connection_attempt_limit before giving up. After this much of retries, the + // client will go offline and cache will not be active from then on permanently. + // 10 by default and 0 is not allowed. + uint32 connection_attempt_limit = 4; + + // The duration in milliseconds between the connection attempts to cluster. + // 5000 by default and 0 is not allowed. + uint32 connection_attempt_period = 5; + + // The timeout value in seconds for a call to be responded by Hazelcast cluster. + // If a member does not respond within the timeout, the lookup or insert operation + // will be cancelled and treated as a cache miss or an aborted insertion. + // 8 by default and 0 is not allowed. + uint32 invocation_timeout = 6; + + // Address and port value of the cluster members. Other fields such as protocol, resolver_name, etc. + // are not required. Only one Hazelcast member address is enough to connect to the cluster but + // providing more than one is recommended. If no address is provided, 127.0.0.1:5701 will be tried + // by default. + repeated envoy.config.core.v3.SocketAddress addresses = 7; + + // Application specific name for the cache. Different deployments should + // use the same prefix and connect to the same Hazelcast cluster if they want + // to share the same cache. Empty string by default. + string app_prefix = 8; + + // In unified mode, cached responses will be stored as a single entry. + // On a range HTTP request, regardless of the request range, all the + // body will be called from the remote cache and then the requested + // range will be served. + // In divided mode, cached responses will be stored in two different + // maps: header map and body map. For a response to be cached, its + // header is stored in the header map and its body is stored in body + // map partitioned with body_partition_size. On a range request, only + // required body partitions are called from the distributed map. This + // option causes extra memory usage per partition on the cache. + // False by default. + bool unified = 9; + + // Body partition size for divided cache. Ignored in unified mode. + // At most 32 KB is allowed. 16 KB by default. + uint64 body_partition_size = 10; +} diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.cc b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.cc new file mode 100644 index 0000000000000..4007de9751a60 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.cc @@ -0,0 +1,155 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h" + +#include "common/protobuf/utility.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +void HazelcastHeaderEntry::writeData(ObjectDataOutput& writer) const { + // Serialization of HeaderEntry to be written to distributed map. + // Order of reading must be the same with this. + writeUnifiedData(writer); + writer.writeInt(version_); + + // Hazelcast stores signed types only. + // Casting to signed before writing and back to unsigned when reading. + writer.writeLong(static_cast(body_size_)); +} + +void HazelcastHeaderEntry::readData(ObjectDataInput& reader) { + // Deserialization of HeaderEntry to be read from distributed map. + // Order of writing must be the same with this. + readUnifiedData(reader); + version_ = reader.readInt(); + int64_t signed_size = reader.readLong(); + std::memcpy(&body_size_, &signed_size, sizeof(uint64_t)); +} + +void HazelcastHeaderEntry::writeUnifiedData(ObjectDataOutput& writer) const { + writer.writeInt(header_map_->size()); + header_map_->iterate( + [](const Http::HeaderEntry& header, void* context) -> Http::HeaderMap::Iterate { + ObjectDataOutput* writer = static_cast(context); + absl::string_view key_view = header.key().getStringView(); + absl::string_view val_view = header.value().getStringView(); + std::vector key_vector(key_view.begin(), key_view.end()); + std::vector val_vector(val_view.begin(), val_view.end()); + writer->writeCharArray(&key_vector); + writer->writeCharArray(&val_vector); + return Http::HeaderMap::Iterate::Continue; + }, + &writer); + std::string serialized; + variant_key_.SerializeToString(&serialized); + // Serialized bytes are binary, not text. String is used as a container only + // during protobuf serialization. Hence ObjectDataOutput::writeUTF might fail. + std::vector bytes(serialized.begin(), serialized.end()); + writer.writeByteArray(&bytes); +} + +void HazelcastHeaderEntry::readUnifiedData(ObjectDataInput& reader) { + int headers_size = reader.readInt(); + header_map_ = std::make_unique(); + for (int i = 0; i < headers_size; i++) { + std::vector key_vector = *reader.readCharArray(); + std::vector val_vector = *reader.readCharArray(); + Http::HeaderString key, val; + key.append(key_vector.data(), key_vector.size()); + val.append(val_vector.data(), val_vector.size()); + header_map_->addViaMove(std::move(key), std::move(val)); + } + std::vector bytes = *reader.readByteArray(); + variant_key_.ParseFromString(std::string(reinterpret_cast(bytes.data()), bytes.size())); +} + +HazelcastHeaderEntry::HazelcastHeaderEntry() = default; + +HazelcastHeaderEntry::HazelcastHeaderEntry(Http::ResponseHeaderMapPtr&& header_map, Key&& key, + uint64_t body_size, int32_t version) + : header_map_(std::move(header_map)), variant_key_(std::move(key)), body_size_(body_size), + version_(version) {} + +HazelcastHeaderEntry::HazelcastHeaderEntry(const HazelcastHeaderEntry& other) { + body_size_ = other.body_size_; + variant_key_ = other.variant_key_; + header_map_ = Http::createHeaderMap(*other.header_map_); + version_ = other.version_; +} + +HazelcastHeaderEntry::HazelcastHeaderEntry(HazelcastHeaderEntry&& other) noexcept + : header_map_(std::move(other.header_map_)), variant_key_(std::move(other.variant_key_)), + body_size_(other.body_size_), version_(other.version_) {} + +bool HazelcastHeaderEntry::operator==(const HazelcastHeaderEntry& other) const { + return body_size_ == other.body_size_ && version_ == other.version_ && + Envoy::Protobuf::util::MessageDifferencer::Equals(variant_key_, other.variant_key_) && + *header_map_ == *other.header_map_; +} + +void HazelcastBodyEntry::writeData(ObjectDataOutput& writer) const { + writeUnifiedData(writer); + writer.writeInt(version_); +} + +void HazelcastBodyEntry::readData(ObjectDataInput& reader) { + readUnifiedData(reader); + version_ = reader.readInt(); +} + +void HazelcastBodyEntry::writeUnifiedData(ObjectDataOutput& writer) const { + writer.writeByteArray(&body_buffer_); +} + +void HazelcastBodyEntry::readUnifiedData(ObjectDataInput& reader) { + body_buffer_ = *reader.readByteArray(); +} + +HazelcastBodyEntry::HazelcastBodyEntry() = default; + +HazelcastBodyEntry::HazelcastBodyEntry(std::vector&& buffer, int32_t version) + : version_(version), body_buffer_(std::move(buffer)) {} + +HazelcastBodyEntry::HazelcastBodyEntry(const HazelcastBodyEntry& other) { + body_buffer_ = other.body_buffer_; + version_ = other.version_; +} + +HazelcastBodyEntry::HazelcastBodyEntry(HazelcastBodyEntry&& other) noexcept + : version_(other.version_), body_buffer_(std::move(other.body_buffer_)) {} + +bool HazelcastBodyEntry::operator==(const HazelcastBodyEntry& other) const { + return version_ == other.version_ && body_buffer_ == other.body_buffer_; +} + +void HazelcastResponseEntry::writeData(ObjectDataOutput& writer) const { + response_header_.writeUnifiedData(writer); + response_body_.writeUnifiedData(writer); +} + +void HazelcastResponseEntry::readData(ObjectDataInput& reader) { + response_header_.readUnifiedData(reader); + response_body_.readUnifiedData(reader); +} + +HazelcastResponseEntry::HazelcastResponseEntry() = default; + +HazelcastResponseEntry::HazelcastResponseEntry(HazelcastHeaderEntry&& header, + HazelcastBodyEntry&& body) + : response_header_(std::move(header)), response_body_(std::move(body)){}; + +bool HazelcastResponseEntry::operator==(const HazelcastResponseEntry& other) const { + // Ignore the fields not written to distributed map. + return response_body_.buffer() == other.body().buffer() && + Envoy::Protobuf::util::MessageDifferencer::Equals(response_header_.variantKey(), + other.header().variantKey()) && + *response_header_.headerMap() == *other.header().headerMap(); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h new file mode 100644 index 0000000000000..f07b4b70c4bb7 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h @@ -0,0 +1,188 @@ +#pragma once + +#include "common/http/header_map_impl.h" + +#include "source/extensions/filters/http/cache/key.pb.h" + +#include "hazelcast/client/EntryView.h" +#include "hazelcast/client/serialization/ObjectDataOutput.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +using hazelcast::client::serialization::DataSerializableFactory; +using hazelcast::client::serialization::IdentifiedDataSerializable; +using hazelcast::client::serialization::ObjectDataInput; +using hazelcast::client::serialization::ObjectDataOutput; + +static const int HAZELCAST_BODY_TYPE_ID = 100; +static const int HAZELCAST_HEADER_TYPE_ID = 101; +static const int HAZELCAST_RESPONSE_TYPE_ID = 102; +static const int HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID = 1000; + +/** + * Response header wrapper for cache entries. + * + * @note In DIVIDED cache mode, response headers and corresponding bodies will be + * stored in different distributed maps. This option is in favor of the efficiency + * of range HTTP requests. For each header entry, there will be body entries (if any) + * with a relevant key. + */ +class HazelcastHeaderEntry : public IdentifiedDataSerializable { +public: + HazelcastHeaderEntry(); + HazelcastHeaderEntry(Http::ResponseHeaderMapPtr&& header_map, Key&& key, uint64_t body_size, + int32_t version); + HazelcastHeaderEntry(const HazelcastHeaderEntry& other); + HazelcastHeaderEntry(HazelcastHeaderEntry&& other) noexcept; + + // hazelcast::client::serialization::IdentifiedDataSerializable + void writeData(ObjectDataOutput& writer) const override; + void readData(ObjectDataInput& reader) override; + int getClassId() const override { return HAZELCAST_HEADER_TYPE_ID; } + int getFactoryId() const override { return HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID; } + + // Only required fields of a header entry for unified mode are de/serialized + // in unifiedData methods. + void writeUnifiedData(ObjectDataOutput& writer) const; + void readUnifiedData(ObjectDataInput& reader); + + Http::ResponseHeaderMapPtr& headerMap() { return header_map_; } + const Http::ResponseHeaderMapPtr& headerMap() const { return header_map_; } + const Key& variantKey() const { return variant_key_; } + uint64_t bodySize() const { return body_size_; } + int32_t version() const { return version_; } + + void variantKey(Key&& key) { variant_key_ = std::move(key); } + void version(int32_t version) { version_ = version; } + + bool operator==(const HazelcastHeaderEntry& other) const; + +private: + Http::ResponseHeaderMapPtr header_map_; + + /** The key generated by the cache filter and modified with vary headers later on. */ + Key variant_key_; + + /** Total body size of the response with these headers. */ + uint64_t body_size_; + + /** Marker to link bodies to header. Bodies in DIVIDED mode will have the same + * version and hence it is ensured a body partition belongs to the correct header. + * Used to handle malformed responses. */ + int32_t version_; +}; + +/** + * Response body wrapper for cache entries. + * + * @note In DIVIDED cache mode, response headers and corresponding bodies will be stored in + * different distributed maps. For a response HeaderEntry with 64-bit hash key , bodies + * will be stored with keys "#0", "#1", "#2".. and so on in a continuous manner. + * Body partition size is fixed and configurable via cache config. On a range request, only + * necessary partitions according to the request will be fetched from distributed map, + * not the whole response. + */ +class HazelcastBodyEntry : public IdentifiedDataSerializable { +public: + HazelcastBodyEntry(); + HazelcastBodyEntry(std::vector&& buffer, int32_t version); + HazelcastBodyEntry(const HazelcastBodyEntry& other); + HazelcastBodyEntry(HazelcastBodyEntry&& other) noexcept; + + // hazelcast::client::serialization::IdentifiedDataSerializable + void writeData(ObjectDataOutput& writer) const override; + void readData(ObjectDataInput& reader) override; + int getClassId() const override { return HAZELCAST_BODY_TYPE_ID; } + int getFactoryId() const override { return HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID; } + + // Only required fields of a header entry for unified mode are de/serialized + // in unifiedData methods. + void writeUnifiedData(ObjectDataOutput& writer) const; + void readUnifiedData(ObjectDataInput& reader); + + size_t length() const { return body_buffer_.size(); } + hazelcast::byte* begin() { return body_buffer_.data(); } + int32_t version() const { return version_; } + const std::vector& buffer() const { return body_buffer_; } + + void version(int32_t version) { version_ = version; } + + bool operator==(const HazelcastBodyEntry& other) const; + +private: + /** Derived from header */ + int32_t version_; + + std::vector body_buffer_; +}; + +/** + * Response wrapper for cache entries. + * + * @note In UNIFIED cache mode, unlike DIVIDED, there is only one cache entry containing + * the response as a whole. Even if a range request arrives, all the body is fetched from + * the cache. This option is in favor of the efficiency of http responses with small body + * sizes. Hence it prevents extra calls for bodies after fetching header. + */ +class HazelcastResponseEntry : public IdentifiedDataSerializable { +public: + HazelcastResponseEntry(); + HazelcastResponseEntry(HazelcastHeaderEntry&& header, HazelcastBodyEntry&& body); + + // hazelcast::client::serialization::IdentifiedDataSerializable + void writeData(ObjectDataOutput& writer) const override; + void readData(ObjectDataInput& reader) override; + int getClassId() const override { return HAZELCAST_RESPONSE_TYPE_ID; } + int getFactoryId() const override { return HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID; } + + HazelcastHeaderEntry& header() { return response_header_; } + HazelcastBodyEntry& body() { return response_body_; } + const HazelcastHeaderEntry& header() const { return response_header_; } + const HazelcastBodyEntry& body() const { return response_body_; } + + bool operator==(const HazelcastResponseEntry& other) const; + +private: + HazelcastHeaderEntry response_header_; + HazelcastBodyEntry response_body_; +}; + +// To make cache compatible with Hazelcast Cpp Client, boost pointers are +// used internally instead of std. +using HazelcastHeaderPtr = boost::shared_ptr; +using HazelcastBodyPtr = boost::shared_ptr; +using HazelcastResponsePtr = boost::shared_ptr; + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +class HazelcastCacheEntrySerializableFactory : public DataSerializableFactory { + +public: + static const int FACTORY_ID = HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID; + + std::auto_ptr create(int32_t class_id) override { + switch (class_id) { + case HAZELCAST_BODY_TYPE_ID: + return std::auto_ptr(new HazelcastBodyEntry()); + case HAZELCAST_HEADER_TYPE_ID: + return std::auto_ptr(new HazelcastHeaderEntry()); + case HAZELCAST_RESPONSE_TYPE_ID: + return std::auto_ptr(new HazelcastResponseEntry()); + default: + return std::auto_ptr(); + } + } +}; + +#pragma GCC diagnostic pop + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.cc b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.cc new file mode 100644 index 0000000000000..d4b23d18af7f5 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.cc @@ -0,0 +1,515 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h" + +#include "common/buffer/buffer_impl.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/util.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +using Envoy::Protobuf::util::MessageDifferencer; + +HazelcastLookupContextBase::HazelcastLookupContextBase(HazelcastHttpCache& cache, + LookupRequest&& request) + : hz_cache_(cache), lookup_request_(std::move(request)) { + createVariantKey(lookup_request_.key()); + variant_key_hash_ = stableHashKey(lookup_request_.key()); +} + +// TODO(enozcan): Support trailers when implemented on the filter side. +void HazelcastLookupContextBase::getTrailers(LookupTrailersCallback&&) { + NOT_IMPLEMENTED_GCOVR_EXCL_LINE; +} + +void HazelcastLookupContextBase::handleLookupFailure(absl::string_view message, + const LookupHeadersCallback& cb, + bool warn_log) { + if (warn_log) { + ENVOY_LOG(warn, "{}", message); + } else { + ENVOY_LOG(debug, "{}", message); + } + abort_insertion_ = true; + cb(LookupResult{}); +} + +void HazelcastLookupContextBase::createVariantKey(Key& raw_key) { + ASSERT(raw_key.custom_fields_size() == 0); + ASSERT(raw_key.custom_ints_size() == 0); // Key must be pure. + if (lookup_request_.varyHeaders().empty()) { + return; + } + std::vector> header_strings; + for (const Http::HeaderEntry& header : lookup_request_.varyHeaders()) { + header_strings.push_back(std::make_pair(std::string(header.key().getStringView()), + std::string(header.value().getStringView()))); + } + arrangeVariantHeaders(raw_key, header_strings); +} + +// Decoupled from HazelcastLookupContextBase::createVariantKey to be able to test. +void HazelcastLookupContextBase::arrangeVariantHeaders( + Key& raw_key, std::vector>& header_strings) { + // Different order of headers causes different hash keys even if their both key and value + // are the same. That is, the following two header lists will cause different hashes for + // the same response and hence they are sorted before insertion. + // + // { {"User-Agent", "desktop"}, {"Accept-Encoding","gzip"} } + // { {"Accept-Encoding","gzip"}, {"User-Agent", "desktop"} } + + std::sort(header_strings.begin(), header_strings.end(), [](auto& left, auto& right) -> bool { + // Per https://tools.ietf.org/html/rfc2616#section-4.2 if two different header entries + // have the same field-name, then their order should not change. For distinct field-named + // headers the order is not significant but sorted alphabetically here to get the same hash + // for the same headers. + return left.first == right.first ? false : left.first < right.first; + }); + + // stableHashKey will create the same variant hashes for the above keys since both + // have the same custom_fields: + // [ "Accept-Encoding", "gzip", "User-Agent", "desktop"] + for (auto& header : header_strings) { + raw_key.add_custom_fields(std::move(header.first)); + raw_key.add_custom_fields(std::move(header.second)); + } + // TODO(enozcan): Ensure the generation of the same hash for the same response independent + // from the header orders. + // Different hashes will be created if the order of values differ for the same + // vary header key. The response will not be affected but the same response will + // be cached with different keys. i.e. two different hashes exist for the followings + // where the only allowed vary header is "accept-language": + // - {accept-language: en-US,tr;q=0.8} + // - {accept-language: tr;q=0.8,en-US} +} + +HazelcastInsertContextBase::HazelcastInsertContextBase(LookupContext& lookup_context, + HazelcastHttpCache& cache) + : hz_cache_(cache), max_body_size_(cache.maxBodyBytes()), + variant_key_hash_(static_cast(lookup_context).variantKeyHash()), + variant_key_(static_cast(lookup_context).variantKey()), + abort_insertion_(static_cast(lookup_context).isAborted()) {} + +// TODO(enozcan): Support trailers when implemented on the filter side. +void HazelcastInsertContextBase::insertTrailers(const Http::ResponseTrailerMap&) { + NOT_IMPLEMENTED_GCOVR_EXCL_LINE; +} + +UnifiedLookupContext::UnifiedLookupContext(HazelcastHttpCache& cache, LookupRequest&& request) + : HazelcastLookupContextBase(cache, std::move(request)) {} + +void UnifiedLookupContext::getHeaders(LookupHeadersCallback&& cb) { + ENVOY_LOG(debug, "Looking up unified response with key hash: {}u", variant_key_hash_); + try { + response_ = hz_cache_.getResponse(variant_key_hash_); + } catch (HazelcastClientOfflineException& e) { + handleLookupFailure("Hazelcast cluster connection is lost! Aborting all lookups and " + "insertions until the connection is restored...", + cb); + return; + } catch (OperationTimeoutException& e) { + handleLookupFailure("Operation timed out during cache lookup.", cb); + return; + } catch (std::exception& e) { + handleLookupFailure(fmt::format("Lookup to cache has failed: {}", e.what()), cb); + return; + } + if (response_) { + ENVOY_LOG(debug, "Found unified response: [key hash: {}u, body size: {}]", variant_key_hash_, + response_->body().length()); + if (!MessageDifferencer::Equals(response_->header().variantKey(), variantKey())) { + // As cache filter denotes, a secondary check other than the hash key + // is performed here. If a different response is found with the same + // hash (probably on hash collisions), the new response is denied to + // be cached and the old one remains. + handleLookupFailure( + "Mismatched keys found for key hash: " + std::to_string(variant_key_hash_), cb, false); + return; + } + cb(lookup_request_.makeLookupResult(std::move(response_->header().headerMap()), + response_->body().length())); + } else { + ENVOY_LOG(debug, "Missed unified response lookup for key hash: {}u", variant_key_hash_); + cb(LookupResult{}); + } +} + +void UnifiedLookupContext::getBody(const AdjustedByteRange& range, LookupBodyCallback&& cb) { + ENVOY_LOG(debug, "Getting unified body (total length = {}) with range: [{}, {}]", + response_->body().length(), range.begin(), range.end()); + ASSERT(response_ && !abort_insertion_); + ASSERT(range.end() <= response_->body().length()); + hazelcast::byte* data = response_->body().begin() + range.begin(); + cb(std::make_unique(data, range.length())); +} + +UnifiedInsertContext::UnifiedInsertContext(LookupContext& lookup_context, HazelcastHttpCache& cache) + : HazelcastInsertContextBase(lookup_context, cache) { + // Unlike DIVIDED mode, lock is not tried to be acquired before insertion here. + // The reason behind tryLock before insertion in DIVIDED context is to prevent + // multiple body entry insertions by different contexts simultaneously. Since + // there is only one entry is to be inserted in UNIFIED mode, a locking mechanism + // is not used here. Multiple contexts can attempt to insert a response for the + // same hash key at the same time and can override an existing one. +} + +void UnifiedInsertContext::insertHeaders(const Http::ResponseHeaderMap& response_headers, + bool end_stream) { + if (abort_insertion_) { + return; + } + ASSERT(!committed_end_stream_); + header_map_ = Http::createHeaderMap(response_headers); + if (end_stream) { + insertResponse(); + } +} + +void UnifiedInsertContext::insertBody(const Buffer::Instance& chunk, + InsertCallback ready_for_next_chunk, bool end_stream) { + if (abort_insertion_) { + if (ready_for_next_chunk) { + ready_for_next_chunk(false); + } + return; + } + ASSERT(!committed_end_stream_); + size_t buffer_length = buffer_vector_.size(); + size_t allowed_size = max_body_size_ - buffer_length; + if (allowed_size > chunk.length()) { + buffer_vector_.resize(buffer_length + chunk.length()); + chunk.copyOut(0, chunk.length(), buffer_vector_.data() + buffer_length); + } else { + // Store the body copied until now and abort the further attempts. + buffer_vector_.resize(max_body_size_); + chunk.copyOut(0, allowed_size, buffer_vector_.data() + buffer_length); + insertResponse(); + ready_for_next_chunk(false); + return; + } + + if (end_stream) { + insertResponse(); + } else if (ready_for_next_chunk) { + ready_for_next_chunk(true); + } +} + +void UnifiedInsertContext::insertResponse() { + ASSERT(!abort_insertion_); + ASSERT(!committed_end_stream_); + ENVOY_LOG(debug, "Inserting unified entry with key hash {}u if absent", variant_key_hash_); + committed_end_stream_ = true; + + // Versions are not necessary for unified entries. Hence passing arbitrary 0 here. + HazelcastHeaderEntry header(std::move(header_map_), std::move(variant_key_), + buffer_vector_.size(), 0); + HazelcastBodyEntry body(std::move(buffer_vector_), 0); + + HazelcastResponseEntry entry(std::move(header), std::move(body)); + try { + hz_cache_.putResponse(variant_key_hash_, entry); + } catch (HazelcastClientOfflineException& e) { + ENVOY_LOG(warn, "Hazelcast cluster connection is lost! Failed to insert response."); + } catch (OperationTimeoutException& e) { + ENVOY_LOG(warn, "Operation timed out during cache insertion."); + } catch (std::exception& e) { + ENVOY_LOG(warn, "Response insertion to cache has failed: {}", e.what()); + } +} + +DividedLookupContext::DividedLookupContext(HazelcastHttpCache& cache, LookupRequest&& request) + : HazelcastLookupContextBase(cache, std::move(request)), + body_partition_size_(cache.bodySizePerEntry()){}; + +void DividedLookupContext::getHeaders(LookupHeadersCallback&& cb) { + ENVOY_LOG(debug, "Looking up divided header with key hash: {}u", variant_key_hash_); + HazelcastHeaderPtr header_entry; + try { + header_entry = hz_cache_.getHeader(variant_key_hash_); + } catch (HazelcastClientOfflineException& e) { + handleLookupFailure("Hazelcast cluster connection is lost! Aborting lookups and " + "insertions until the connection is restored.", + cb); + return; + } catch (OperationTimeoutException& e) { + handleLookupFailure("Operation timed out during cache lookup.", cb); + return; + } catch (std::exception& e) { + handleLookupFailure(fmt::format("Lookup to cache has failed: {}", e.what()), cb); + return; + } + if (header_entry) { + ENVOY_LOG(debug, "Found divided response: [key: {}u, version: {}, body size: {}]", + variant_key_hash_, header_entry->version(), header_entry->bodySize()); + if (!MessageDifferencer::Equals(header_entry->variantKey(), variantKey())) { + handleLookupFailure( + "Mismatched keys found for key hash: " + std::to_string(variant_key_hash_), cb, false); + return; + } + this->total_body_size_ = header_entry->bodySize(); + this->version_ = header_entry->version(); + this->found_header_ = true; + cb(lookup_request_.makeLookupResult(std::move(header_entry->headerMap()), total_body_size_)); + } else { + ENVOY_LOG(debug, "Missed divided response lookup for key hash: {}u", variant_key_hash_); + cb(LookupResult{}); + } +} + +// Hence bodies are stored partially on the cache (see hazelcast_cache_entry.h for details), +// the returning buffer from this function can have a size of at most body_partition_size_. +// The caller (filter) has to check range and make another getBody request if needed. +// +// For instance, for a response of which body is 5 KB length, the cached entries will look +// like the following with 2 KB of body_partition_size_ configured: +// +// --> HazelcastHeaderEntry(response headers) +// +// --> HazelcastBodyEntry(0-2 KB) +// --> HazelcastBodyEntry(2-4 KB) +// --> HazelcastBodyEntry(4-5 KB) +// +void DividedLookupContext::getBody(const AdjustedByteRange& range, LookupBodyCallback&& cb) { + ASSERT(range.end() <= total_body_size_); + ASSERT(found_header_, "Header lookup is missed."); + + // Lookup for only one body partition which includes the range.begin(). + uint64_t body_index = range.begin() / body_partition_size_; + HazelcastBodyPtr body; + ENVOY_LOG(debug, "Looking up divided body with key hash: {}u, order: {}", variant_key_hash_, + body_index); + try { + body = hz_cache_.getBody(variant_key_hash_, body_index); + } catch (HazelcastClientOfflineException& e) { + handleBodyLookupFailure("Hazelcast cluster connection is lost! Aborting lookups and " + "insertions until the connection is restored...", + cb); + return; + } catch (OperationTimeoutException& e) { + handleBodyLookupFailure("Operation timed out during cache lookup.", cb); + return; + } catch (std::exception& e) { + handleBodyLookupFailure(fmt::format("Lookup to cache for body entry has failed: {}", e.what()), + cb); + return; + } + + if (body) { + ENVOY_LOG(debug, "Found divided body: [key: {}u + \"#{}\", version: {}, size: {}]", + variant_key_hash_, body_index, body->version(), body->length()); + if (body->version() != version_) { + hz_cache_.onVersionMismatch(variant_key_hash_, version_, total_body_size_); + handleBodyLookupFailure( + fmt::format("Body version mismatched with header for " + "key {}u at body: {}. Aborting lookup and performing cleanup.", + variant_key_hash_, body_index), + cb, false); + return; + } + uint64_t offset = (range.begin() % body_partition_size_); + hazelcast::byte* data = body->begin() + offset; + if (range.end() < (body_index + 1) * body_partition_size_) { + // No other body partition is needed since this one satisfies the + // range. Callback with the appropriate body bytes. + cb(std::make_unique(data, range.length())); + } else { + // The range requests bytes from the next body partition as well. + // Callback with the bytes until the end of the current partition. + cb(std::make_unique(data, body->length() - offset)); + } + } else { + // Body partition is expected to reside in the cache but lookup is failed. + hz_cache_.onMissingBody(variant_key_hash_, version_, total_body_size_); + handleBodyLookupFailure(fmt::format("Found missing body for key {}u at index: {}. Response " + "with body size {} has been cleaned up from the cache.", + variant_key_hash_, body_index, total_body_size_), + cb, false); + } +}; + +void DividedLookupContext::handleBodyLookupFailure(absl::string_view message, + const LookupBodyCallback& cb, bool warn_log) { + if (warn_log) { + ENVOY_LOG(warn, "{}", message); + } else { + ENVOY_LOG(debug, "{}", message); + } + cb(nullptr); +} + +DividedInsertContext::DividedInsertContext(LookupContext& lookup_context, HazelcastHttpCache& cache) + : HazelcastInsertContextBase(lookup_context, cache), + body_partition_size_(cache.bodySizePerEntry()), version_(createVersion()) { + try { + // To prevent multiple insertion contexts to create the same response in the cache, + // mark only one of them responsible for the insertion using Hazelcast map key locks. + // If key is not locked, it will be acquired here and only one insertion context + // will be responsible for the insertion. This is also valid when multiple cache + // filters from different proxies are connected to the same Hazelcast cluster. + // There is no such a mechanism for UNIFIED mode. + insertion_allowed_ = hz_cache_.tryLock(variant_key_hash_); + return; + } catch (HazelcastClientOfflineException& e) { + ENVOY_LOG(warn, "Hazelcast cluster connection is lost! Aborting lookups and insertions until " + "the connection is restored..."); + } catch (OperationTimeoutException& e) { + ENVOY_LOG(warn, "Operation timed out during tryLock!"); + } catch (std::exception& e) { + ENVOY_LOG(warn, "Lock trial has failed: {}", e.what()); + } + insertion_allowed_ = false; +} + +void DividedInsertContext::insertHeaders(const Http::ResponseHeaderMap& response_headers, + bool end_stream) { + if (abort_insertion_ || !insertion_allowed_) { + return; + } + ASSERT(!committed_end_stream_); + header_map_ = Http::createHeaderMap(response_headers); + if (end_stream) { + insertHeader(); + } +} + +// Body insertions in DIVIDED cache mode must be performed over a fixed sized buffer +// hence continuity of the body partitions are ensured. To do this, insertion chunk's +// content is copied into a local buffer every time insertBody is called. And it is +// flushed when it reaches the maximum capacity (body_partition_size_). +void DividedInsertContext::insertBody(const Buffer::Instance& chunk, + InsertCallback ready_for_next_chunk, bool end_stream) { + if (abort_insertion_ || !insertion_allowed_) { + ENVOY_LOG(debug, "Skipping insertion for the hash key: {}u", variant_key_hash_); + if (ready_for_next_chunk) { + ready_for_next_chunk(false); + } + return; + } + ASSERT(!committed_end_stream_); + uint64_t copied_bytes = 0; + uint64_t allowed_bytes = + max_body_size_ - (body_order_ * body_partition_size_ + buffer_vector_.size()); + uint64_t remaining_bytes = allowed_bytes < chunk.length() ? allowed_bytes : chunk.length(); + bool trimmed = remaining_bytes == allowed_bytes; + while (remaining_bytes) { + uint64_t available_bytes = body_partition_size_ - buffer_vector_.size(); + if (available_bytes < remaining_bytes) { + // This chunk is going to fill the buffer. Copy as much bytes as possible + // into the buffer, flush the buffer and continue with the remaining bytes. + copyIntoLocalBuffer(copied_bytes, available_bytes, chunk); + ASSERT(buffer_vector_.size() == body_partition_size_); + remaining_bytes -= available_bytes; + if (!flushBuffer()) { + // Abort insertion if one of the body insertions fails. + if (ready_for_next_chunk) { + ready_for_next_chunk(false); + } + return; + } + } else { + // Copy all the bytes starting from chunk[copied_bytes] into buffer. Current + // buffer can hold the remaining data. + copyIntoLocalBuffer(copied_bytes, remaining_bytes, chunk); + break; + } + } + + if (end_stream || trimmed) { + // Header shouldn't be inserted before body insertions are completed. + // Total body size in the header entry is computed via inserted body partitions. + if (flushBuffer()) { + // Header insertion is performed only when all bodies are stored. + // Otherwise, insertion will be aborted and another insert context + // will store the response by overriding body entries flushed so far. + insertHeader(); + } + } + if (ready_for_next_chunk) { + ready_for_next_chunk(!trimmed); + } +} + +void DividedInsertContext::copyIntoLocalBuffer(uint64_t& offset, uint64_t size, + const Buffer::Instance& source) { + uint64_t current_size = buffer_vector_.size(); + buffer_vector_.resize(current_size + size); + source.copyOut(offset, size, buffer_vector_.data() + current_size); + offset += size; +}; + +bool DividedInsertContext::flushBuffer() { + ASSERT(!abort_insertion_); + ASSERT(insertion_allowed_); + if (buffer_vector_.empty()) { + return true; + } + total_body_size_ += buffer_vector_.size(); + HazelcastBodyEntry bodyEntry(std::move(buffer_vector_), version_); + buffer_vector_.clear(); + try { + hz_cache_.putBody(variant_key_hash_, body_order_++, bodyEntry); + } catch (HazelcastClientOfflineException& e) { + ENVOY_LOG(warn, "Hazelcast cluster connection is lost!"); + return false; + } catch (OperationTimeoutException& e) { + ENVOY_LOG(warn, "Operation timed out during body insertion."); + return false; + } catch (std::exception& e) { + ENVOY_LOG(warn, "Body insertion to cache has failed: {}", e.what()); + return false; + } + if (body_order_ == ConfigUtil::partitionWarnLimit()) { + ENVOY_LOG(warn, "Number of body partitions for a response has been reached {} (or more).", + ConfigUtil::partitionWarnLimit()); + ENVOY_LOG(info, "Having so many partitions might cause performance drop " + "as well as extra memory usage. Consider increasing body " + "partition size."); + } + return true; +} + +void DividedInsertContext::insertHeader() { + ASSERT(!abort_insertion_); + ASSERT(insertion_allowed_); + ASSERT(!committed_end_stream_); + committed_end_stream_ = true; + HazelcastHeaderEntry header(std::move(header_map_), std::move(variant_key_), total_body_size_, + version_); + try { + hz_cache_.putHeader(variant_key_hash_, header); + hz_cache_.unlock(variant_key_hash_); + ENVOY_LOG(debug, "Inserted header entry with key {}u", variant_key_hash_); + // To handle leftover locks in a failure, hazelcast.lock.max.lease.time.seconds property + // must be set to a reasonable value on the server side. It is Long.MAX by default. + // To make this independent from the server configuration, tryLock with leaseTime + // option can be used when available in a future release of cpp client as it's implemented + // at: https://github.com/hazelcast/hazelcast-cpp-client/issues/579 + // TODO(enozcan): Use tryLock with leaseTime when released for Hazelcast cpp client. + } catch (HazelcastClientOfflineException& e) { + ENVOY_LOG(warn, "Hazelcast Connection is offline!"); + } catch (std::exception& e) { + ENVOY_LOG(warn, "Failed to complete response insertion: {}", e.what()); + } +} + +int32_t DividedInsertContext::createVersion() { + // We do not need a strong uniformity or randomness here. Even + // the versions of two different header entries with distinct + // hash keys are the same, this will not cause a problem at all. + // We only need a stamp for bodies which inserted for this context. + // Since this version is stored in cache entries, a 32-bit random + // derived from the 64-bit one is preferred here. + // Range: from (int32.MIN + 1) to (int32.MAX - 1), inclusive. + uint64_t rand64 = hz_cache_.random(); + uint64_t max = std::numeric_limits::max(); + return (rand64 % (max * 2)) - max; +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h new file mode 100644 index 0000000000000..1ac51bf753874 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h @@ -0,0 +1,212 @@ +#pragma once + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +/** + * Base lookup context for both UNIFIED and DIVIDED cache lookups. + */ +class HazelcastLookupContextBase : public LookupContext, + public Logger::Loggable { +public: + HazelcastLookupContextBase(HazelcastHttpCache& cache, LookupRequest&& request); + + // LookupContext + void getTrailers(LookupTrailersCallback&&) override; + + const Key& variantKey() const { return lookup_request_.key(); } + uint64_t variantKeyHash() const { return variant_key_hash_; } + bool isAborted() const { return abort_insertion_; } + +protected: + void handleLookupFailure(absl::string_view message, const LookupHeadersCallback& cb, + bool warn_log = true); + + HazelcastHttpCache& hz_cache_; + LookupRequest lookup_request_; + + /** Hash key aware of vary headers. Lookup to header and response entry is performed using this + * key. */ + uint64_t variant_key_hash_; + + /** Flag to notice insert context created for this lookup */ + bool abort_insertion_ = false; + +private: + friend class HazelcastHttpCacheTest; + + /** + * The keys created by the cache filter for lookups and inserts are not aware + * of the vary headers of the request. Instead, cache filter expects a + * cache plugin to differentiate responses having the same key by their vary + * headers. Rather than storing multiple responses with the same key and + * then querying them according to vary headers, a different key for each + * response including vary headers in custom fields is created here. Hence + * responses can be found by their directly without querying. + * + * @param raw_key Key to be modified created by the filter. + */ + void createVariantKey(Key& raw_key); + + /** + * Fills the custom_fields of a key with given headers in alphabetical order. + * + * @note Decoupled from createVariantKey for testing. + * @param raw_key Key to be modified created by the filter. + * @param headers Vary headers to be included in the key. + */ + void arrangeVariantHeaders(Key& raw_key, + std::vector>& headers); +}; + +/** + * Base insert context for both UNIFIED and DIVIDED cache insertions. + */ +class HazelcastInsertContextBase : public InsertContext, + public Logger::Loggable { +public: + HazelcastInsertContextBase(LookupContext& lookup_context, HazelcastHttpCache& cache); + + // InsertContext + void insertTrailers(const Http::ResponseTrailerMap&) override; + +protected: + HazelcastHttpCache& hz_cache_; + + // From HazelcastHttpCache configuration + const uint64_t max_body_size_; + + bool committed_end_stream_ = false; + + // Derived from lookup context + const uint64_t variant_key_hash_; + Key variant_key_; + const bool abort_insertion_; + + // Response fields + /** Body content is first copied into this buffer and then written to distributed map. */ + std::vector buffer_vector_; + + /** Response headers to be inserted */ + Http::ResponseHeaderMapPtr header_map_; +}; + +/** + * Lookup context for UNIFIED cache. + */ +class UnifiedLookupContext : public HazelcastLookupContextBase { +public: + UnifiedLookupContext(HazelcastHttpCache& cache, LookupRequest&& request); + void getHeaders(LookupHeadersCallback&& cb) override; + void getBody(const AdjustedByteRange& range, LookupBodyCallback&& cb) override; + +private: + /** Response to be inserted */ + HazelcastResponsePtr response_; +}; + +/** + * Insert context for UNIFIED cache. + */ +class UnifiedInsertContext : public HazelcastInsertContextBase { +public: + UnifiedInsertContext(LookupContext& lookup_context, HazelcastHttpCache& cache); + void insertHeaders(const Http::ResponseHeaderMap& response_headers, bool end_stream) override; + void insertBody(const Buffer::Instance& chunk, InsertCallback ready_for_next_chunk, + bool end_stream) override; + +private: + /** + * Wraps the current response content with HazelcastResponseEntry and puts + * into the cache. + */ + void insertResponse(); +}; + +/** + * Lookup context for DIVIDED cache. + */ +class DividedLookupContext : public HazelcastLookupContextBase { +public: + DividedLookupContext(HazelcastHttpCache& cache, LookupRequest&& request); + void getHeaders(LookupHeadersCallback&& cb) override; + void getBody(const AdjustedByteRange& range, LookupBodyCallback&& cb) override; + +private: + void handleBodyLookupFailure(absl::string_view message, const LookupBodyCallback& cb, + bool warn_log = true); + + /** Values fetched from the cache after a successful lookup */ + bool found_header_ = false; + int32_t version_; + uint64_t total_body_size_; + + /** Max body size per body entry defined via cache config. */ + const uint64_t body_partition_size_; +}; + +/** + * Insert context for DIVIDED cache. + */ +class DividedInsertContext : public HazelcastInsertContextBase { +public: + DividedInsertContext(LookupContext& lookup_context, HazelcastHttpCache& cache); + void insertHeaders(const Http::ResponseHeaderMap& response_headers, bool end_stream) override; + void insertBody(const Buffer::Instance& chunk, InsertCallback ready_for_next_chunk, + bool end_stream) override; + +private: + /** + * Copies bytes from source to local buffer. Insertion to the cache happens after + * the local buffer is full or the end stream is committed by the filter. + * @param offset Byte offset for the source. Updated much after copying. + * @param size Number of bytes to be copied into the local buffer. + * @param source Body content given by the filter. + */ + void copyIntoLocalBuffer(uint64_t& offset, uint64_t size, const Buffer::Instance& source); + + /** + * Wraps the current body buffer with HazelcastBodyEntry and puts + * into the cache. + * + * @return True if insertion is completed. + */ + bool flushBuffer(); + + /** + * Wraps the current header map, request key, body size and version values with + * HazelcastHeaderEntry and puts into the cache. + */ + void insertHeader(); + + /** + * Creates a common version for a header and its body entries. + * This version denotes the relation between a header and its + * bodies such that they are inserted by the same insert context + * for the same lookup in DIVIDED mode. + */ + int32_t createVersion(); + + /** Counter for the order of next body entry to be inserted. */ + int body_order_ = 0; + + /** Max body size per body entry defined via config. */ + const uint64_t body_partition_size_; + + /** Response specific values to be used in the cached entries */ + const int32_t version_; + uint64_t total_body_size_ = 0; + + bool insertion_allowed_ = true; +}; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.cc b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.cc new file mode 100644 index 0000000000000..f5d4b49818dbc --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.cc @@ -0,0 +1,170 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h" +#include "extensions/filters/http/cache/hazelcast_http_cache/util.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +using hazelcast::client::ClientConfig; +using hazelcast::client::exception::HazelcastClientOfflineException; +using hazelcast::client::serialization::DataSerializableFactory; + +HazelcastHttpCache::HazelcastHttpCache( + HazelcastHttpCacheConfig&& typed_config, + const envoy::extensions::filters::http::cache::v3alpha::CacheConfig& cache_config) + : unified_(typed_config.unified()), + body_partition_size_(ConfigUtil::validPartitionSize(typed_config.body_partition_size())), + max_body_bytes_( + ConfigUtil::validMaxBodySize(cache_config.max_body_bytes(), typed_config.unified())), + cache_config_(std::move(typed_config)) {} + +void HazelcastHttpCache::onMissingBody(uint64_t key_hash, int32_t version, uint64_t body_size) { + try { + if (!tryLock(key_hash)) { + // If multiple onMissingBody calls are made for the same key hash simultaneously, + // the locking here will allow only one of them to perform clean up. + return; + } + auto header = getHeader(key_hash); + if (header && header->version() != version) { + // The missed body does not belong to the looked up header. Probably eviction and then + // insertion for the header has happened in the meantime. Since new insertion will + // override the existing bodies, ignore the cleanup and let orphan bodies (belong to + // evicted header, not overridden) be evicted by TTL as well. + unlock(key_hash); + return; + } + int body_count = body_size / body_partition_size_; + while (body_count >= 0) { + accessor_->removeBodyAsync(orderedMapKey(key_hash, body_count--)); + } + accessor_->removeHeader(mapKey(key_hash)); + unlock(key_hash); + } catch (HazelcastClientOfflineException& e) { + // see DividedInsertContext::insertHeader for left over locks on a connection failure. + ENVOY_LOG(warn, "Hazelcast Connection is offline!"); + } catch (std::exception& e) { + ENVOY_LOG(warn, "Clean up for missing body has failed: {}", e.what()); + } +} + +void HazelcastHttpCache::onVersionMismatch(uint64_t key_hash, int32_t version, uint64_t body_size) { + onMissingBody(key_hash, version, body_size); +} + +void HazelcastHttpCache::start(StorageAccessorPtr&& accessor) { + if (accessor_ && accessor_->isRunning()) { + ENVOY_LOG(warn, "Client is already connected. Cluster name: {}", accessor_->clusterName()); + return; + } + + if (!accessor_) { + accessor_ = std::move(accessor); + } + + try { + accessor_->connect(); + } catch (...) { + accessor_.reset(); + throw EnvoyException("Hazelcast Client could not connect to any cluster."); + } + ENVOY_LOG(info, accessor_->startInfo()); +} + +void HazelcastHttpCache::shutdown(bool destroy) { + if (!accessor_) { + ENVOY_LOG(warn, "Cache is already offline."); + return; + } + if (accessor_->isRunning()) { + ENVOY_LOG(info, "Shutting down Hazelcast connection..."); + accessor_->disconnect(); + ENVOY_LOG(info, "Cache is offline now."); + } else { + ENVOY_LOG(warn, "Hazelcast client is already disconnected."); + } + if (destroy) { + accessor_.reset(); + } +} + +HazelcastHttpCache::~HazelcastHttpCache() { shutdown(true); } + +LookupContextPtr HazelcastHttpCache::makeLookupContext(LookupRequest&& request) { + if (unified_) { + return std::make_unique(*this, std::move(request)); + } else { + return std::make_unique(*this, std::move(request)); + } +} + +InsertContextPtr HazelcastHttpCache::makeInsertContext(LookupContextPtr&& lookup_context) { + ASSERT(lookup_context != nullptr); + if (unified_) { + return std::make_unique(*lookup_context, *this); + } else { + return std::make_unique(*lookup_context, *this); + } +} + +// TODO(enozcan): Implement when it's ready on the filter side. +// Depending on the filter's implementation, the cached entry's +// variant_key_ must be updated as well. Also, if vary headers +// change then the key hash of the response will change and +// updating only header map will not be enough in this case. +void HazelcastHttpCache::updateHeaders(LookupContextPtr&&, Http::ResponseHeaderMapPtr&&) { + NOT_IMPLEMENTED_GCOVR_EXCL_LINE; +} + +constexpr absl::string_view HazelcastCacheName = "envoy.extensions.http.cache.hazelcast"; + +// Cluster wide cache statistics should be observed on Hazelcast Management Center. +// They are not stored locally. +CacheInfo HazelcastHttpCache::cacheInfo() const { + CacheInfo cache_info; + cache_info.name_ = HazelcastCacheName; + cache_info.supports_range_requests_ = true; + return cache_info; +} + +std::string HazelcastHttpCacheFactory::name() const { return std::string(HazelcastCacheName); } + +ProtobufTypes::MessagePtr HazelcastHttpCacheFactory::createEmptyConfigProto() { + return std::make_unique(); +} + +HttpCache& HazelcastHttpCacheFactory::getCache( + const envoy::extensions::filters::http::cache::v3alpha::CacheConfig& config) { + if (!cache_) { + HazelcastHttpCacheConfig typed_config; + MessageUtil::unpackTo(config.typed_config(), typed_config); + ClientConfig client_config = ConfigUtil::getClientConfig(typed_config); + cache_ = std::make_unique(std::move(typed_config), config); + StorageAccessorPtr accessor = std::make_unique( + *cache_, std::move(client_config), cache_->prefix(), cache_->bodySizePerEntry()); + cache_->start(std::move(accessor)); + } + return *cache_; +} + +HazelcastHttpCachePtr HazelcastHttpCacheFactory::getOfflineCache( + const envoy::extensions::filters::http::cache::v3alpha::CacheConfig& config) { + if (!cache_) { + HazelcastHttpCacheConfig hz_cache_config; + MessageUtil::unpackTo(config.typed_config(), hz_cache_config); + cache_ = std::make_unique(std::move(hz_cache_config), config); + } + return std::move(cache_); +} + +static Registry::RegisterFactory register_; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h new file mode 100644 index 0000000000000..724b002e33bbd --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h @@ -0,0 +1,278 @@ +#pragma once + +#include "common/common/logger.h" +#include "common/runtime/runtime_impl.h" + +#include "source/extensions/filters/http/cache/hazelcast_http_cache/config.pb.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.h" +#include "extensions/filters/http/cache/http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +// TODO(enozcan): Consider putting responses into cache with TTL derived from `max-age` header +// instead of using a common TTL for all. This is possible during insertion by passing TTL +// amount regardless of the configured TTL on Hazelcast server side. +// i.e: IMap::put(const K &key, const V &value, int64_t ttlInMilliseconds); + +using envoy::source::extensions::filters::http::cache::HazelcastHttpCacheConfig; + +/** + * HttpCache implementation backed by Hazelcast. + * + * Supports two cache modes: UNIFIED and DIVIDED. + * + * In UNIFIED mode, an HTTP response is wrapped by a HazelcastResponseEntry + * with all its fields (headers, body, trailers, request key) and stored in + * distributed map. On a range HTTP request, regardless of the requested + * range, the whole response body is fetched from the cache. + * + * In DIVIDED mode, an HTTP response's fields except for its body are wrapped + * by a HazelcastHeaderEntry. Its body is divided into chunks with certain + * sizes and then stored in another distributed map as HazelcastBodyEntry. On + * a range request, not the whole body for the response but only the necessary + * partitions are fetched from the cache. A header and its bodies have a common + * number named to interrelate multiple entries belong to the same response. + * + */ +class HazelcastHttpCache : public HttpCache, + public Logger::Loggable { +public: + HazelcastHttpCache( + HazelcastHttpCacheConfig&& typed_config, + const envoy::extensions::filters::http::cache::v3alpha::CacheConfig& cache_config); + + /// Divided mode + + /** + * Puts a header entry into header cache. + * @param key_hash Hash of the filter's cache key + * @param entry Entry to be inserted + * @note Generated hashes should be consistent across restarts, architectures, + * builds, and configurations. Otherwise, different filters using the + * same Hazelcast cluster might store the same response with different + * hashes. + */ + void putHeader(const uint64_t key_hash, const HazelcastHeaderEntry& entry) { + accessor_->putHeader(mapKey(key_hash), entry); + } + + /** + * Puts a body entry into body cache. + * @param key_hash Hash of the filter's cache key + * @param order Order of the body chunk among other partitions starting from 0 + * @param entry Entry to be inserted + * @note The map key of a body partition must be obtainable from its header's + * key hash. + */ + void putBody(const uint64_t key_hash, const uint64_t order, const HazelcastBodyEntry& entry) { + accessor_->putBody(orderedMapKey(key_hash, order), entry); + } + + /** + * Performs a lookup to header cache for the given key hash. + * @param key_hash Hash of the filter's cache key + * @return HazelcastHeaderPtr to cached entry if found, nullptr otherwise + */ + HazelcastHeaderPtr getHeader(const uint64_t key_hash) { + return accessor_->getHeader(mapKey(key_hash)); + } + + /** + * Performs a lookup to body cache for the given key hash and order pair. + * @param key_hash Hash of the filter's cache key + * @param order Order of the body chunk among other partitions + * @return HazelcastBodyPtr to cached entry if found, nullptr otherwise + */ + HazelcastBodyPtr getBody(const uint64_t key_hash, const uint64_t order) { + return accessor_->getBody(orderedMapKey(key_hash, order)); + } + + /** + * Cleans up a malformed response when at least one of the body chunks are missed + * during lookup. The header for the response is removed to make a new insertion + * available by an insert context and the remaining body partitions are removed + * to prevent orphan body entries stay in the cache. + * @param key_hash Hash of the filter's cache key + * @param version Version of the header and body + * @param body_size Total body size of the response + */ + void onMissingBody(uint64_t key_hash, int32_t version, uint64_t body_size); + + /** + * Cleans up a malformed response when a body partition with different version + * than the header is encountered during lookup. + * @param key_hash Hash of the filter's cache key + * @param version Version of the header and body + * @param body_size Total body size of the response + */ + void onVersionMismatch(uint64_t key_hash, int32_t version, uint64_t body_size); + + /// Unified mode + + /** + * Puts a unified entry into unified cache. + * @param key_hash Hash of the filter's cache key + * @param entry Entry to be inserted + */ + void putResponse(const uint64_t key_hash, const HazelcastResponseEntry& entry) { + accessor_->putResponse(mapKey(key_hash), entry); + } + + /** + * Performs a lookup to unified cache for the given key hash. + * @param key_hash Hash of the filter's cache key + * @return HazelcastResponsePtr to cached entry if found, nullptr otherwise + */ + HazelcastResponsePtr getResponse(const uint64_t key_hash) { + return accessor_->getResponse(mapKey(key_hash)); + } + + /// Common + + /** + * Attempts to lock the given key in the cache. When a key is locked, a lookup + * can be performed but an insertion or update for the key must be prevented + * for threads other than the lock holder. + * @param key_hash Hash of the filter's cache key + * @return True if acquired, false otherwise + * @note Used to prevent multiple insertions or updates of the same + * response by different contexts at a time. + */ + bool tryLock(const uint64_t key_hash) { return accessor_->tryLock(mapKey(key_hash), unified_); } + + /** + * Releases the lock for the key hash. + * @param key_hash Hash of the filter's cache key + */ + void unlock(const uint64_t key_hash) { accessor_->unlock(mapKey(key_hash), unified_); } + + /** + * Produces a random number. + * @return Random unsigned long + * @note The primary use case of the random number is to generate version + * for header and body entries in DIVIDED mode. + */ + uint64_t random() { return rand_.random(); } + + /** + * @return Size in bytes for a single body entry configured for the cache + * @note Ignored in UNIFIED mode. + */ + uint64_t bodySizePerEntry() const { return body_partition_size_; } + + /** + * @return Allowed max size in bytes for a response configured for the cache + * @note Common for both modes. For a response which has a body larger + * than this limit, the first max_body_size_ bytes of the response + * will be cached only. + */ + uint64_t maxBodyBytes() const { return max_body_bytes_; } + + bool unified() const { return unified_; } + + const std::string& prefix() const { return cache_config_.app_prefix(); } + + /** + * Makes the cache ready to serve using the accessor. + * @param accessor Accessor to cache storage + * @note The accessor passed by the factory must establish a Hazelcast cluster + * connection. Other local storage accessors might be used during tests to + * test the cache behavior without running a real Hazelcast instance. + */ + void start(StorageAccessorPtr&& accessor); + + /** + * Drops accessor connection to the storage. + * @param destroy True if accessor_ also should be destroyed. + */ + void shutdown(bool destroy); + + // from Cache::HttpCache + LookupContextPtr makeLookupContext(LookupRequest&& request) override; + InsertContextPtr makeInsertContext(LookupContextPtr&& lookup_context) override; + void updateHeaders(LookupContextPtr&& lookup_context, + Http::ResponseHeaderMapPtr&& response_headers) override; + CacheInfo cacheInfo() const override; + + ~HazelcastHttpCache() override; + +private: + friend class HazelcastHttpCacheTestBase; + + /** + * Generates a Hazelcast map key from the hash of the filter's cache key. + * @param key_hash Hash of the filter's cache key + * @return Hazelcast map key + * @note Hazelcast client accepts signed map keys only. + */ + int64_t mapKey(const uint64_t key_hash) { + // The reason for not static casting directly is a possible overflow + // for int64 on intermediate step for -2^63. + int64_t signed_key; + std::memcpy(&signed_key, &key_hash, sizeof(int64_t)); + return signed_key; + } + + /** + * Creates string keys for body partition entries obtainable from the hash of the + * filter's cache key. + * @param key_hash Hash of the filter's cache key + * @param order Order of the body among other partitions starting from 0 + * @return Hazelcast map key for body entry + * @note Appending '#' or any other marker between the key and order + * string is required. Otherwise, for instance, the 11th order + * body for key 1 and the 1st order body for key 11 will have + * the same map key "111". + */ + std::string orderedMapKey(const uint64_t key_hash, const uint64_t order) { + return std::to_string(key_hash).append("#").append(std::to_string(order)); + } + + StorageAccessorPtr accessor_; + + /** Cache mode */ + const bool unified_; + + /** Partition size in bytes for a single body entry */ + const uint64_t body_partition_size_; + + /** Allowed max body size for a response */ + const uint64_t max_body_bytes_; + + /** typed config from CacheConfig */ + HazelcastHttpCacheConfig cache_config_; + + Runtime::RandomGeneratorImpl rand_; +}; + +using HazelcastHttpCachePtr = std::unique_ptr; + +class HazelcastHttpCacheFactory : public HttpCacheFactory { +public: + // UntypedFactory + std::string name() const override; + + // TypedFactory + ProtobufTypes::MessagePtr createEmptyConfigProto() override; + + // HttpCacheFactory + HttpCache& + getCache(const envoy::extensions::filters::http::cache::v3alpha::CacheConfig& config) override; + + HazelcastHttpCachePtr // For testing only. + getOfflineCache(const envoy::extensions::filters::http::cache::v3alpha::CacheConfig& config); + +private: + HazelcastHttpCachePtr cache_; +}; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.cc b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.cc new file mode 100644 index 0000000000000..b6bf451d176b1 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.cc @@ -0,0 +1,128 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +void HazelcastClusterAccessor::putHeader(const int64_t map_key, const HazelcastHeaderEntry& value) { + getHeaderMap().set(map_key, value); +} + +void HazelcastClusterAccessor::putBody(const std::string& map_key, + const HazelcastBodyEntry& value) { + getBodyMap().set(map_key, value); +} + +HazelcastHeaderPtr HazelcastClusterAccessor::getHeader(const int64_t map_key) { + return getHeaderMap().get(map_key); +} + +HazelcastBodyPtr HazelcastClusterAccessor::getBody(const std::string& map_key) { + return getBodyMap().get(map_key); +} + +void HazelcastClusterAccessor::removeBodyAsync(const std::string& map_key) { + getBodyMap().removeAsync(map_key); +} + +void HazelcastClusterAccessor::removeHeader(const int64_t map_key) { + getHeaderMap().deleteEntry(map_key); +} + +void HazelcastClusterAccessor::putResponse(const int64_t map_key, + const HazelcastResponseEntry& value) { + getResponseMap().set(map_key, value); +} + +HazelcastResponsePtr HazelcastClusterAccessor::getResponse(const int64_t map_key) { + return getResponseMap().get(map_key); +} + +// Internal lock mechanism of Hazelcast specific to map and key pair is +// used to make exactly one lookup context responsible for insertions. It +// is and also used to secure consistency during updateHeaders(). These +// locks prevent possible race for multiple cache filters from multiple +// proxies when they connect to the same Hazelcast cluster. The locks used +// here are re-entrant. A locked key can be acquired by the same thread +// again and again based on its pid. +bool HazelcastClusterAccessor::tryLock(const int64_t map_key, bool unified) { + return unified ? getResponseMap().tryLock(map_key) : getHeaderMap().tryLock(map_key); +} + +// Hazelcast does not allow a thread to unlock a key unless it's the key +// owner. To handle this, IMap::forceUnlock is called here to make sure +// the lock is released certainly. +void HazelcastClusterAccessor::unlock(const int64_t map_key, bool unified) { + if (unified) { + getResponseMap().forceUnlock(map_key); + } else { + getHeaderMap().forceUnlock(map_key); + } +} + +bool HazelcastClusterAccessor::isRunning() const { + return hazelcast_client_ ? hazelcast_client_->getLifecycleService().isRunning() : false; +} + +std::string HazelcastClusterAccessor::clusterName() const { + return hazelcast_client_ ? hazelcast_client_->getClientConfig().getGroupConfig().getName() : ""; +} + +void HazelcastClusterAccessor::disconnect() { + if (hazelcast_client_) { + hazelcast_client_->shutdown(); + } +} + +HazelcastClusterAccessor::HazelcastClusterAccessor(HazelcastHttpCache& cache, + ClientConfig&& client_config, + const std::string& app_prefix, + const uint64_t partition_size) + : cache_(cache), app_prefix_(app_prefix), partition_size_(partition_size), + client_config_(std::move(client_config)) { + body_map_name_ = constructMapName("body", false); + header_map_name_ = constructMapName("div", false); + response_map_name_ = constructMapName("uni", true); +} + +void HazelcastClusterAccessor::connect() { + if (hazelcast_client_ && hazelcast_client_->getLifecycleService().isRunning()) { + return; + } + hazelcast_client_ = std::make_unique(client_config_); + listener_ = std::make_unique(cache_); + getHeaderMap().addEntryListener(*listener_, true); +} + +std::string HazelcastClusterAccessor::startInfo() const { + return absl::StrFormat( + "HazelcastHttpCache is created with profile: %s. Max body size: %d.\n" + "Cache statistics can be observed on Hazelcast Management Center " + "from the map named %s.", + cache_.unified() ? "UNIFIED" : "DIVIDED, partition size: " + std::to_string(partition_size_), + cache_.maxBodyBytes(), cache_.unified() ? response_map_name_ : header_map_name_); +} + +std::string HazelcastClusterAccessor::constructMapName(const std::string& postfix, bool unified) { + return absl::StrFormat("%s-%d-%s", app_prefix_, unified ? cache_.maxBodyBytes() : partition_size_, + postfix); +} + +void HeaderMapEntryListener::entryEvicted(const EntryEvent& event) { + auto header = event.getOldValueObject(); + int64_t map_key = *event.getKeyObject(); + uint64_t unsigned_key; + std::memcpy(&unsigned_key, &map_key, sizeof(uint64_t)); + // Clean up all bodies of this header via onMissingBody procedure. + cache_.onMissingBody(unsigned_key, header->version(), header->bodySize()); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.h b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.h new file mode 100644 index 0000000000000..4037846f1d14c --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_storage_accessor.h @@ -0,0 +1,172 @@ +#pragma once + +#include "envoy/common/exception.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h" + +#include "absl/strings/str_format.h" +#include "hazelcast/client/HazelcastClient.h" +#include "hazelcast/client/IMap.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +/** + * Abstraction for storage connections of the cache. + * + * @note Decoupled from the cache in favor of local storage implementations + * or mocks to test the cache without running a real Hazelcast Instance. + */ +class StorageAccessor { +public: + StorageAccessor() = default; + + virtual void putHeader(const int64_t map_key, const HazelcastHeaderEntry& value) PURE; + virtual void putBody(const std::string& map_key, const HazelcastBodyEntry& value) PURE; + virtual void putResponse(const int64_t map_key, const HazelcastResponseEntry& value) PURE; + + virtual HazelcastHeaderPtr getHeader(const int64_t map_key) PURE; + virtual HazelcastBodyPtr getBody(const std::string& map_key) PURE; + virtual HazelcastResponsePtr getResponse(const int64_t map_key) PURE; + + virtual void removeBodyAsync(const std::string& map_key) PURE; + virtual void removeHeader(const int64_t map_key) PURE; + + virtual bool tryLock(const int64_t map_key, bool unified) PURE; + virtual void unlock(const int64_t map_key, bool unified) PURE; + + virtual bool isRunning() const PURE; + virtual std::string clusterName() const PURE; + virtual std::string startInfo() const PURE; + + virtual void connect() PURE; + virtual void disconnect() PURE; + + virtual ~StorageAccessor() = default; +}; + +using StorageAccessorPtr = std::unique_ptr; +class HazelcastHttpCache; +class HeaderMapEntryListener; + +/** + * Accessor to Hazelcast Cluster. + * + * The cache uses this accessor in the production code. + */ +class HazelcastClusterAccessor : public virtual StorageAccessor { +public: + HazelcastClusterAccessor(HazelcastHttpCache& cache, ClientConfig&& client_config, + const std::string& app_prefix, const uint64_t partition_size); + + void putHeader(const int64_t map_key, const HazelcastHeaderEntry& value) override; + void putBody(const std::string& map_key, const HazelcastBodyEntry& value) override; + void putResponse(const int64_t map_key, const HazelcastResponseEntry& value) override; + + HazelcastHeaderPtr getHeader(const int64_t map_key) override; + HazelcastBodyPtr getBody(const std::string& map_key) override; + HazelcastResponsePtr getResponse(const int64_t map_key) override; + + void removeBodyAsync(const std::string& map_key) override; + void removeHeader(const int64_t map_key) override; + + bool tryLock(const int64_t map_key, bool unified) override; + void unlock(const int64_t map_key, bool unified) override; + + bool isRunning() const override; + std::string clusterName() const override; + std::string startInfo() const override; + + void connect() override; + void disconnect() override; + + ~HazelcastClusterAccessor() override = default; + +private: + friend class RemoteTestAccessor; + + /** + * Generates a map name unique to the cache configuration. + * + * @note Maps with the same key & value types are differentiated by their names in + * Hazelcast cluster. Hence each plugin will connect to a map named with partition + * size and app_prefix. When a cache connects to a cluster which already has an active + * cache with different body_partition_size, this naming will prevent incompatibility + * and separate these two caches in the Hazelcast cluster. + */ + std::string constructMapName(const std::string& postfix, bool unified); + + /** Returns remote header cache proxy */ + IMap getHeaderMap() { + if (!hazelcast_client_) { + throw EnvoyException("Hazelcast Client is not connected to a cluster."); + } + return hazelcast_client_->getMap(header_map_name_); + } + + /** Returns remote body cache proxy */ + IMap getBodyMap() { + if (!hazelcast_client_) { + throw EnvoyException("Hazelcast Client is not connected to a cluster."); + } + return hazelcast_client_->getMap(body_map_name_); + } + + /** Returns remote response cache proxy */ + IMap getResponseMap() { + if (!hazelcast_client_) { + throw EnvoyException("Hazelcast Client is not connected to a cluster."); + } + return hazelcast_client_->getMap(response_map_name_); + } + + std::unique_ptr hazelcast_client_; + std::unique_ptr listener_; + HazelcastHttpCache& cache_; + + // From HazelcastCacheConfig + const std::string& app_prefix_; + uint64_t partition_size_; + + ClientConfig client_config_; + + std::string body_map_name_; + std::string header_map_name_; + std::string response_map_name_; +}; + +using hazelcast::client::EntryEvent; + +/** + * HeaderMap listener to clean up orphan bodies of which header is evicted. + * + * @note This handler is kicked only when a header entry is evicted, i.e. max configured + * size is reached on HeaderMap and then eviction is performed. On a TTL or idleTime based + * expiration, this listener will not take an action since it should be handled by the + * TTL/maxIdleTime configuration of BodyMap configured on the server side. + */ +class HeaderMapEntryListener + : public hazelcast::client::EntryListener { +public: + HeaderMapEntryListener(HazelcastHttpCache& cache) : cache_(cache) {} + void entryEvicted(const EntryEvent& event) override; + void entryAdded(const EntryEvent&) override {} + void entryRemoved(const EntryEvent&) override {} + void entryUpdated(const EntryEvent&) override {} + void entryExpired(const EntryEvent&) override {} + void entryMerged(const EntryEvent&) override {} + void mapEvicted(const MapEvent&) override {} + void mapCleared(const MapEvent&) override {} + +private: + HazelcastHttpCache& cache_; +}; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/hazelcast_http_cache/util.h b/source/extensions/filters/http/cache/hazelcast_http_cache/util.h new file mode 100644 index 0000000000000..dc531458a57f9 --- /dev/null +++ b/source/extensions/filters/http/cache/hazelcast_http_cache/util.h @@ -0,0 +1,111 @@ +#pragma once + +#include "source/extensions/filters/http/cache/hazelcast_http_cache/config.pb.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h" + +#include "hazelcast/client/ClientConfig.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +class ConfigUtil { +public: + static uint64_t validPartitionSize(const uint64_t config_value) { + return config_value == 0 + ? DEFAULT_PARTITION_SIZE + : (config_value > MAX_ALLOWED_PARTITION_SIZE) ? MAX_ALLOWED_PARTITION_SIZE + : config_value; + } + + static uint64_t validMaxBodySize(const uint64_t config_value, const bool unified) { + if (unified) { + // Apply size limitation for single entry (unified response) on the map. + return config_value == 0 || (config_value > MAX_ALLOWED_UNIFIED_BODY_SIZE) + ? MAX_ALLOWED_UNIFIED_BODY_SIZE + : config_value; + } else { + // In divided mode, no upper limit for a body size exists. Instead, the max number of + // partitions for a response is indirectly set by (max_body_size / partition_size) ratio + // in the plugin configuration. + return config_value == 0 ? DEFAULT_MAX_DIVIDED_BODY_SIZE : config_value; + } + } + + static hazelcast::client::ClientConfig + getClientConfig(const envoy::source::extensions::filters::http::cache::HazelcastHttpCacheConfig& + cache_config) { + hazelcast::client::ClientConfig config; + config.getGroupConfig().setName(cache_config.group_name()); + config.getGroupConfig().setPassword(cache_config.group_password()); + for (auto& address : cache_config.addresses()) { + config.getNetworkConfig().addAddress( + hazelcast::client::Address(address.address(), address.port_value())); + } + config.getNetworkConfig().setConnectionTimeout(cache_config.connection_timeout() == 0 + ? DEFAULT_CONNECTION_TIMEOUT_MS + : cache_config.connection_timeout()); + config.getNetworkConfig().setConnectionAttemptLimit( + cache_config.connection_attempt_limit() == 0 ? DEFAULT_CONNECTION_ATTEMPT_LIMIT + : cache_config.connection_attempt_limit()); + config.getNetworkConfig().setConnectionAttemptPeriod( + cache_config.connection_attempt_period() == 0 ? DEFAULT_CONNECTION_ATTEMPT_PERIOD_MS + : cache_config.connection_attempt_period()); + config.getConnectionStrategyConfig().setReconnectMode( + hazelcast::client::config::ClientConnectionStrategyConfig::ReconnectMode::ASYNC); + config.setProperty("hazelcast.client.invocation.timeout.seconds", + std::to_string(cache_config.invocation_timeout() == 0 + ? DEFAULT_INVOCATION_TIMEOUT_SEC + : cache_config.invocation_timeout())); + + config.getSerializationConfig().addDataSerializableFactory( + HazelcastCacheEntrySerializableFactory::FACTORY_ID, + boost::shared_ptr(new HazelcastCacheEntrySerializableFactory())); + return config; + } + + static uint16_t partitionWarnLimit() { return PARTITION_WARN_LIMIT; } + +private: + friend class ConfigUtilsTest; + + // After this much body partitions stored for a response in DIVIDED mode, + // a suggestion log will be appeared to increase partition size. + static constexpr uint16_t PARTITION_WARN_LIMIT = 16; + + // Default size for each divided body entry. + static constexpr uint64_t DEFAULT_PARTITION_SIZE = 1024 * 16; + + // IMap is not optimized for large value sizes. Hence an upper limit is introduced for + // each stored body entry. + // Maximum allowed size for each divided body entry. + static constexpr uint64_t MAX_ALLOWED_PARTITION_SIZE = 1024 * 32; + + // The limit to keep a single map entry size reasonable. + // Maximum allowed total body size of a unified response. + static constexpr uint64_t MAX_ALLOWED_UNIFIED_BODY_SIZE = 1024 * 32; + + // Default maximum body size of a divided response. + static constexpr uint64_t DEFAULT_MAX_DIVIDED_BODY_SIZE = 1024 * 256; + + // Duration to try to reconnect a cluster if a member does not respond. + static constexpr uint32_t DEFAULT_CONNECTION_TIMEOUT_MS = 5000; + + // Limit of connection attempts before go offline. + static constexpr uint32_t DEFAULT_CONNECTION_ATTEMPT_LIMIT = 10; + + // Duration between connection retries. + static constexpr uint32_t DEFAULT_CONNECTION_ATTEMPT_PERIOD_MS = 5000; + + // Duration for an invocation to be cancelled. + static constexpr uint32_t DEFAULT_INVOCATION_TIMEOUT_SEC = 8; +}; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/http_cache.h b/source/extensions/filters/http/cache/http_cache.h index de3dde3120f48..32131f76ba5a6 100644 --- a/source/extensions/filters/http/cache/http_cache.h +++ b/source/extensions/filters/http/cache/http_cache.h @@ -165,6 +165,17 @@ class LookupRequest { // Caches may modify the key according to local needs, though care must be // taken to ensure that meaningfully distinct responses have distinct keys. const Key& key() const { return key_; } + Key& key() { return key_; } + + // Returns the subset of this request's headers that are listed in + // envoy::extensions::filters::http::cache::v3alpha::CacheConfig::allowed_vary_headers. If a cache + // storage implementation forwards lookup requests to a remote cache server that supports *vary* + // headers, that server may need to see these headers. For local implementations, it may be + // simpler to instead call makeLookupResult with each potential response. + const HeaderVector& varyHeaders() const { return vary_headers_; } + + // Time when this LookupRequest was created (in response to an HTTP request). + SystemTime timestamp() const { return timestamp_; } // WARNING: Incomplete--do not use in production (yet). // Returns a LookupResult suitable for sending to the cache filter's @@ -183,13 +194,7 @@ class LookupRequest { Key key_; std::vector request_range_spec_; - // Time when this LookupRequest was created (in response to an HTTP request). SystemTime timestamp_; - // The subset of this request's headers that are listed in - // envoy::extensions::filters::http::cache::v3alpha::CacheConfig::allowed_vary_headers. If a cache - // storage implementation forwards lookup requests to a remote cache server that supports *vary* - // headers, that server may need to see these headers. For local implementations, it may be - // simpler to instead call makeLookupResult with each potential response. HeaderVector vary_headers_; const std::string request_cache_control_; }; diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/BUILD b/test/extensions/filters/http/cache/hazelcast_http_cache/BUILD new file mode 100644 index 0000000000000..28a03a79c004f --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/BUILD @@ -0,0 +1,70 @@ +load("//bazel:envoy_build_system.bzl", "envoy_package") +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", + "envoy_extension_cc_test_library", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "hazelcast_cache_config_test", + srcs = ["hazelcast_cache_config_test.cc"], + extension_name = "envoy.filters.http.cache.hazelcast_http_cache", + deps = [ + ":hazelcast_test_lib", + ], +) + +envoy_extension_cc_test( + name = "hazelcast_common_cache_test", + srcs = ["hazelcast_common_cache_test.cc"], + extension_name = "envoy.filters.http.cache.hazelcast_http_cache", + deps = [ + ":hazelcast_test_lib", + ], +) + +envoy_extension_cc_test( + name = "hazelcast_divided_cache_test", + srcs = ["hazelcast_divided_cache_test.cc"], + extension_name = "envoy.filters.http.cache.hazelcast_http_cache", + deps = [ + ":hazelcast_test_lib", + ], +) + +envoy_extension_cc_test( + name = "hazelcast_entry_serialization_test", + srcs = ["hazelcast_entry_serialization_test.cc"], + extension_name = "envoy.filters.http.cache.hazelcast_http_cache", + deps = [ + ":hazelcast_test_lib", + ], +) + +envoy_extension_cc_test( + name = "hazelcast_unified_cache_test", + srcs = ["hazelcast_unified_cache_test.cc"], + extension_name = "envoy.filters.http.cache.hazelcast_http_cache", + deps = [ + ":hazelcast_test_lib", + ], +) + +envoy_extension_cc_test_library( + name = "hazelcast_test_lib", + hdrs = [ + "test_accessors.h", + "test_util.h", + ], + extension_name = "envoy.filters.http.cache.hazelcast_http_cache", + deps = [ + "//source/extensions/filters/http/cache/hazelcast_http_cache:hazelcast_http_cache_lib", + "//test/test_common:logging_lib", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_config_test.cc b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_config_test.cc new file mode 100644 index 0000000000000..0fa750e28eac8 --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_config_test.cc @@ -0,0 +1,101 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/util.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +using envoy::source::extensions::filters::http::cache::HazelcastHttpCacheConfig; + +class ConfigUtilsTest : public testing::Test { +protected: + // Private configuration values from HazelcastHttpCache::ConfigUtil + uint64_t defaultPartitionSize() { return ConfigUtil::DEFAULT_PARTITION_SIZE; } + uint64_t maxPartitionSize() { return ConfigUtil::MAX_ALLOWED_PARTITION_SIZE; } + uint64_t maxUnifiedBodySize() { return ConfigUtil::MAX_ALLOWED_UNIFIED_BODY_SIZE; } + uint64_t defaultMaxDividedBodySize() { return ConfigUtil::DEFAULT_MAX_DIVIDED_BODY_SIZE; } + uint32_t defaultConnectionTimeoutMs() { return ConfigUtil::DEFAULT_CONNECTION_TIMEOUT_MS; } + uint32_t defaultConnectionAttemptLimit() { return ConfigUtil::DEFAULT_CONNECTION_ATTEMPT_LIMIT; } + uint32_t defaultConnectionAttemptPeriodMs() { + return ConfigUtil::DEFAULT_CONNECTION_ATTEMPT_PERIOD_MS; + } + uint32_t defaultInvocationTimeoutSec() { return ConfigUtil::DEFAULT_INVOCATION_TIMEOUT_SEC; } + uint16_t partitionWarnLimit() { return ConfigUtil::PARTITION_WARN_LIMIT; } +}; + +TEST_F(ConfigUtilsTest, ValidPartitionSizeTest) { + uint64_t valid_value = ConfigUtil::validPartitionSize(0); + EXPECT_EQ(defaultPartitionSize(), valid_value); + valid_value = ConfigUtil::validPartitionSize(maxPartitionSize() + 1); + EXPECT_EQ(maxPartitionSize(), valid_value); + valid_value = ConfigUtil::validPartitionSize(maxPartitionSize() - 1); + EXPECT_EQ(maxPartitionSize() - 1, valid_value); +} + +TEST_F(ConfigUtilsTest, ValidMaxBodySizeTest) { + { + // unified + uint64_t max_size = maxUnifiedBodySize(); + uint64_t valid_value = ConfigUtil::validMaxBodySize(0, true); + EXPECT_EQ(max_size, valid_value); + valid_value = ConfigUtil::validMaxBodySize(max_size + 1, true); + EXPECT_EQ(max_size, valid_value); + valid_value = ConfigUtil::validMaxBodySize(max_size - 1, true); + EXPECT_EQ(max_size - 1, valid_value); + } + { + // divided + uint64_t default_max_size = defaultMaxDividedBodySize(); + uint64_t valid_value = ConfigUtil::validMaxBodySize(0, false); + EXPECT_EQ(default_max_size, valid_value); + valid_value = ConfigUtil::validMaxBodySize(default_max_size + 1, false); + // there is no upper limit for the configured max body size in divided mode. + EXPECT_EQ(default_max_size + 1, valid_value); + valid_value = ConfigUtil::validMaxBodySize(default_max_size - 1, false); + EXPECT_EQ(default_max_size - 1, valid_value); + } +} + +TEST_F(ConfigUtilsTest, ClientConfigTest) { + const std::string group_name = "group_foo"; + const std::string group_pass = "foo_pass"; + const std::string member_address = "192.168.10.3"; // arbitrary address + constexpr int member_port = 5703; // arbitrary port + + HazelcastHttpCacheConfig default_cache_config; + default_cache_config.set_group_name(group_name); + default_cache_config.set_group_password(group_pass); + ::envoy::config::core::v3::SocketAddress* address = default_cache_config.add_addresses(); + address->set_address(member_address); + address->set_port_value(member_port); + + hazelcast::client::ClientConfig config = ConfigUtil::getClientConfig(default_cache_config); + + // Defaults below are not defined by Hazelcast but the cache plugin. So, the below statements + // test if plugin sets the Hazelcast client configuration properly. + EXPECT_EQ(defaultConnectionTimeoutMs(), config.getNetworkConfig().getConnectionTimeout()); + EXPECT_EQ(defaultConnectionAttemptLimit(), config.getNetworkConfig().getConnectionAttemptLimit()); + EXPECT_EQ(defaultConnectionAttemptPeriodMs(), + config.getNetworkConfig().getConnectionAttemptPeriod()); + EXPECT_STREQ(std::to_string(defaultInvocationTimeoutSec()).c_str(), + config.getProperties()["hazelcast.client.invocation.timeout.seconds"].c_str()); + EXPECT_STREQ(group_name.c_str(), config.getGroupConfig().getName().c_str()); + EXPECT_STREQ(group_pass.c_str(), config.getGroupConfig().getPassword().c_str()); + std::vector addresses = config.getNetworkConfig().getAddresses(); + EXPECT_EQ(1, addresses.size()); + EXPECT_STREQ(member_address.c_str(), addresses.at(0).getHost().c_str()); + EXPECT_EQ(member_port, addresses.at(0).getPort()); +} + +TEST_F(ConfigUtilsTest, WarnLimitTest) { + EXPECT_EQ(partitionWarnLimit(), ConfigUtil::partitionWarnLimit()); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_common_cache_test.cc b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_common_cache_test.cc new file mode 100644 index 0000000000000..2cbf643b17b8e --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_common_cache_test.cc @@ -0,0 +1,259 @@ +#include "envoy/registry/registry.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h" + +#include "test/extensions/filters/http/cache/hazelcast_http_cache/test_util.h" +#include "test/test_common/logging.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +/** + * Common tests for both DIVIDED and UNIFIED cache mode. + */ +class HazelcastHttpCacheTest : public HazelcastHttpCacheTestBase, + public testing::WithParamInterface { +protected: + void SetUp() override { + HazelcastHttpCacheConfig typed_config = HazelcastTestUtil::getTestTypedConfig(GetParam()); + envoy::extensions::filters::http::cache::v3alpha::CacheConfig cache_config = + HazelcastTestUtil::getTestCacheConfig(); + cache_ = std::make_unique(std::move(typed_config), cache_config); + // To test the cache with a real Hazelcast instance, use remote test accessor. + // cache_->start(HazelcastTestUtil::getTestRemoteAccessor(*cache_)); + cache_->start(std::make_unique()); + getTestAccessor().clearMaps(); + } + + Key getVariantKey(LookupContextPtr& lookup, + std::vector>& headers) { + HazelcastLookupContextBase& hz_lookup = static_cast(*lookup); + Key variant_key = hz_lookup.variantKey(); + hz_lookup.arrangeVariantHeaders(variant_key, headers); + return variant_key; + } +}; + +INSTANTIATE_TEST_SUITE_P(CommonCacheTests, HazelcastHttpCacheTest, ::testing::Bool()); + +TEST_P(HazelcastHttpCacheTest, MissPutAndGetEntries) { + // To test divided body behavior as well, bodies having sizes near the limit are preferred. + const std::string RequestPath1("/body/with/limit/size/plus/one"); + const std::string RequestPath2("/body/with/exact/limit/size"); + const std::string RequestPath3("/body/with/limit/size/minus/one"); + + LookupContextPtr lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + LookupContextPtr lookup_context2 = lookup(RequestPath2); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + LookupContextPtr lookup_context3 = lookup(RequestPath3); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + int length = HazelcastTestUtil::TEST_PARTITION_SIZE * 2; + const std::string Body1(length + 1, 's'); + absl::string_view Body2(Body1.c_str(), length); + absl::string_view Body3(Body1.c_str(), length - 1); + + insert(move(lookup_context1), getResponseHeaders(), Body1); + insert(move(lookup_context2), getResponseHeaders(), Body2); + insert(move(lookup_context3), getResponseHeaders(), Body3); + + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath1).get(), Body1)); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath2).get(), Body2)); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath3).get(), Body3)); + EXPECT_EQ(GetParam(), cache_->unified()); +} + +TEST_P(HazelcastHttpCacheTest, HandleRangedResponses) { + const std::string RequestPath("/ranged/responses"); + LookupContextPtr lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + int size = HazelcastTestUtil::TEST_PARTITION_SIZE; + const std::string Body = std::string(size, 'h') + std::string(size, 'z') + std::string(size, 'c'); + insert(move(lookup_context), getResponseHeaders(), Body); + lookup_context = lookup(RequestPath); + + // 'h' * (size) + EXPECT_EQ(absl::string_view(Body.c_str(), size), getBody(*lookup_context, 0, size)); + + // 'z' * (size) + EXPECT_EQ(absl::string_view(Body.c_str() + size, size), getBody(*lookup_context, size, size * 2)); + + // 'h' * (size/2) + 'z' * (size/2) + EXPECT_EQ(absl::string_view(Body.c_str() + size / 2, size), + getBody(*lookup_context, size / 2, size + size / 2)); + + // 'h' + 'z' * (size) + 'c' + EXPECT_EQ(absl::string_view(Body.c_str() + size - 1, size + 2), + getBody(*lookup_context, size - 1, 2 * size + 1)); + + // 'h' * (size) + 'z' * (size) + 'c' * (size) + EXPECT_EQ(absl::string_view(Body.c_str(), size * 3), getBody(*lookup_context, 0, size * 3)); +} + +TEST_P(HazelcastHttpCacheTest, VariantKeyTest) { + // The same key should be created for the same vary headers even if their + // order is different. + LookupContextPtr lookup_context = lookup("/variant/key/test/"); + std::vector> vary_headers; + vary_headers.push_back(std::make_pair("Accept-Language", "tr;q=0.8")); + vary_headers.push_back(std::make_pair("User-Agent", "desktop")); + auto key1 = getVariantKey(lookup_context, vary_headers); + vary_headers.clear(); + vary_headers.push_back(std::make_pair("User-Agent", "desktop")); + vary_headers.push_back(std::make_pair("Accept-Language", "tr;q=0.8")); + auto key2 = getVariantKey(lookup_context, vary_headers); + + EXPECT_EQ(4, key1.custom_fields_size()); // 2 keys, 2 values, 4 in total + EXPECT_EQ(4, key2.custom_fields_size()); + EXPECT_TRUE(Envoy::Protobuf::util::MessageDifferencer::Equals(key1, key2)); + EXPECT_EQ(stableHashKey(key1), stableHashKey(key2)); +} + +// +// Tests belong to SimpleHttpCache are applied below with minor changes on the test bodies. +// +TEST_P(HazelcastHttpCacheTest, SimplePutGet) { + const std::string RequestPath1("/simple/put/first"); + LookupContextPtr name_lookup_context = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body1("hazelcast"); + insert(move(name_lookup_context), getResponseHeaders(), Body1); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath1).get(), Body1)); + + const std::string RequestPath2("/simple/put/second"); + name_lookup_context = lookup(RequestPath2); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body2("hazelcast.http.cache"); + insert(move(name_lookup_context), getResponseHeaders(), Body2); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath2).get(), Body2)); +} + +TEST_P(HazelcastHttpCacheTest, PrivateResponse) { + const std::string request_path("/private/response"); + + LookupContextPtr name_lookup_context = lookup(request_path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body("Value"); + + insert(move(name_lookup_context), getResponseHeaders(), Body); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(request_path).get(), Body)); +} + +TEST_P(HazelcastHttpCacheTest, Miss) { + LookupContextPtr name_lookup_context = lookup("/no/such/entry"); + uint64_t variant_key_hash = + static_cast(*name_lookup_context).variantKeyHash(); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // Do not left over a missed lookup without inserting or releasing its lock. + // This is required for remote accessor. + cache_->unlock(variant_key_hash); +} + +TEST_P(HazelcastHttpCacheTest, Fresh) { + insert("/", getResponseHeaders(), ""); + time_source_.advanceTimeWait(std::chrono::seconds(3600)); + lookup("/"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); +} + +TEST_P(HazelcastHttpCacheTest, Stale) { + insert("/", getResponseHeaders(), ""); + time_source_.advanceTimeWait(std::chrono::seconds(3601)); + lookup("/"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); +} + +TEST_P(HazelcastHttpCacheTest, RequestSmallMinFresh) { + request_headers_.setReferenceKey(Http::Headers::get().CacheControl, "min-fresh=1000"); + const std::string request_path("/request/small/min/fresh"); + LookupContextPtr name_lookup_context = lookup(request_path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + Http::TestResponseHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"age", "6000"}, + {"cache-control", "public, max-age=9000"}}; + const std::string Body("content"); + insert(move(name_lookup_context), response_headers, Body); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(request_path).get(), Body)); +} + +TEST_P(HazelcastHttpCacheTest, ResponseStaleWithRequestLargeMaxStale) { + request_headers_.setReferenceKey(Http::Headers::get().CacheControl, "max-stale=9000"); + + const std::string request_path("/response/stale/with/request/large/max/stale"); + LookupContextPtr name_lookup_context = lookup(request_path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + Http::TestResponseHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"age", "7200"}, + {"cache-control", "public, max-age=3600"}}; + + const std::string Body("content"); + insert(move(name_lookup_context), response_headers, Body); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(request_path).get(), Body)); +} + +TEST_P(HazelcastHttpCacheTest, StreamingPutAndRangeGet) { + InsertContextPtr inserter = cache_->makeInsertContext(lookup("/streaming/put")); + inserter->insertHeaders(getResponseHeaders(), false); + inserter->insertBody( + Buffer::OwnedImpl("Hello, "), [](bool ready) { EXPECT_TRUE(ready); }, false); + inserter->insertBody(Buffer::OwnedImpl("World!"), nullptr, true); + LookupContextPtr name_lookup_context = lookup("/streaming/put"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + EXPECT_NE(nullptr, lookup_result_.headers_); + ASSERT_EQ(13, lookup_result_.content_length_); + EXPECT_EQ("Hello, World!", getBody(*name_lookup_context, 0, 13)); + EXPECT_EQ("o, World!", getBody(*name_lookup_context, 4, 13)); +} + +TEST_P(HazelcastHttpCacheTest, CacheStartShutdown) { + EXPECT_LOG_CONTAINS("warn", "Client is already connected.", + cache_->start(std::make_unique())); + cache_->shutdown(false); + EXPECT_LOG_CONTAINS("warn", "Hazelcast client is already disconnected.", cache_->shutdown(true)); +} + +TEST(Registration, GetFactory) { + HttpCacheFactory* factory = Registry::FactoryRegistry::getFactoryByType( + "envoy.source.extensions.filters.http.cache.HazelcastHttpCacheConfig"); + ASSERT_NE(factory, nullptr); + envoy::extensions::filters::http::cache::v3alpha::CacheConfig config; + HazelcastHttpCacheConfig typed_config = HazelcastTestUtil::getTestTypedConfig(true); + typed_config.set_group_name("do-not-connect-any-cluster"); + typed_config.set_connection_attempt_limit(1); + typed_config.set_connection_attempt_period(1); // give up immediately. + ClientConfig client_config = ConfigUtil::getClientConfig(typed_config); + config.mutable_typed_config()->PackFrom(typed_config); + + { + // getOfflineCache() call is for testing. It creates a HazelcastHttpCache but does + // not make it operational until a start() call. This is required to make cacheInfo() + // behavior testable when using local accessor. + HazelcastHttpCachePtr cache = + static_cast(factory)->getOfflineCache(config); + EXPECT_EQ(cache->cacheInfo().name_, "envoy.extensions.http.cache.hazelcast"); + + StorageAccessorPtr accessor = std::make_unique( + *cache, std::move(client_config), cache->prefix(), cache->bodySizePerEntry()); + EXPECT_THROW_WITH_MESSAGE(cache->start(std::move(accessor)), EnvoyException, + "Hazelcast Client could not connect to any cluster."); + } + EXPECT_THROW_WITH_MESSAGE(factory->getCache(config), EnvoyException, + "Hazelcast Client could not connect to any cluster."); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_divided_cache_test.cc b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_divided_cache_test.cc new file mode 100644 index 0000000000000..793c63c71a96b --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_divided_cache_test.cc @@ -0,0 +1,499 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h" + +#include "test/extensions/filters/http/cache/hazelcast_http_cache/test_util.h" + +#include "gmock/gmock.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +/** + * Tests for DIVIDED cache mode. + */ +class HazelcastDividedCacheTest : public HazelcastHttpCacheTestBase { +protected: + void SetUp() override { + HazelcastHttpCacheConfig typed_config = HazelcastTestUtil::getTestTypedConfig(false); + envoy::extensions::filters::http::cache::v3alpha::CacheConfig cache_config = + HazelcastTestUtil::getTestCacheConfig(); + cache_ = std::make_unique(std::move(typed_config), cache_config); + // To test the cache with a real Hazelcast instance, use remote test accessor. + // cache_->start(HazelcastTestUtil::getTestRemoteAccessor(*cache_)); + cache_->start(std::make_unique()); + getTestAccessor().clearMaps(); + } +}; + +template class MockEntryEvictedEvent : public EntryEvent { +public: + MockEntryEvictedEvent(const Member& member) + : EntryEvent("MockEntryEvent", member, EntryEventType::EVICTED) {} + MOCK_METHOD(const K*, getKeyObject, (), (const)); + MOCK_METHOD(const V*, getOldValueObject, (), (const)); +}; + +TEST_F(HazelcastDividedCacheTest, AbortDividedInsertionWhenMaxSizeReached) { + const std::string RequestPath("/abort/when/max/size/reached"); + InsertContextPtr insert_context = cache_->makeInsertContext(lookup(RequestPath)); + insert_context->insertHeaders(getResponseHeaders(), false); + bool ready_for_next = true; + while (ready_for_next) { + insert_context->insertBody( + Buffer::OwnedImpl(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h')), + [&](bool ready) { ready_for_next = ready; }, false); + } + + EXPECT_EQ(((HazelcastTestUtil::TEST_MAX_BODY_SIZE + HazelcastTestUtil::TEST_PARTITION_SIZE - 1) / + HazelcastTestUtil::TEST_PARTITION_SIZE), + getTestAccessor().bodyMapSize()); + EXPECT_TRUE(expectLookupSuccessWithFullBody( + lookup(RequestPath).get(), std::string(HazelcastTestUtil::TEST_MAX_BODY_SIZE, 'h'))); +} + +TEST_F(HazelcastDividedCacheTest, AllowOverridingCacheEntries) { + const std::string RequestPath("/allow/override/cached/response"); + + LookupContextPtr lookup_context = lookup(RequestPath); + const std::string OriginalBody(HazelcastTestUtil::TEST_PARTITION_SIZE * 3, 'h'); + insert(move(lookup_context), getResponseHeaders(), OriginalBody); + + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + + const std::string OverriddenBody(HazelcastTestUtil::TEST_PARTITION_SIZE * 2, 'z'); + insert(move(lookup_context), getResponseHeaders(), OverriddenBody); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath).get(), OverriddenBody)); +} + +TEST_F(HazelcastDividedCacheTest, CleanBodyOnHeaderEviction) { + LookupContextPtr lookup_context = lookup("/header/eviction/"); + uint64_t variant_key_hash = + static_cast(*lookup_context).variantKeyHash(); + const int BodyCount = 3; + insert(move(lookup_context), getResponseHeaders(), + std::string(HazelcastTestUtil::TEST_PARTITION_SIZE * BodyCount, 'h')); + EXPECT_EQ(1, getTestAccessor().headerMapSize()); + EXPECT_EQ(BodyCount, getTestAccessor().bodyMapSize()); + + auto key_object = std::make_unique(mapKey(variant_key_hash)); + auto value_object = cache_->getHeader(variant_key_hash); + EXPECT_NE(nullptr, value_object); + + Member m; + MockEntryEvictedEvent mock_event(m); + EXPECT_CALL(mock_event, getOldValueObject()).WillRepeatedly(testing::Return(value_object.get())); + EXPECT_CALL(mock_event, getKeyObject()).WillRepeatedly(testing::Return(key_object.get())); + + EXPECT_EQ(BodyCount, getTestAccessor().bodyMapSize()); + + HeaderMapEntryListener listener(*cache_); + listener.entryAdded(mock_event); // no-op + listener.entryRemoved(mock_event); // no-op + listener.entryUpdated(mock_event); // no-op + listener.entryExpired(mock_event); // no-op + listener.entryMerged(mock_event); // no-op + + MapEvent null_event(m, EntryEventType::UNDEFINED, "null_event", 0); + listener.mapEvicted(null_event); // no-op + listener.mapCleared(null_event); // no-op + + EXPECT_EQ(BodyCount, getTestAccessor().bodyMapSize()); + listener.entryEvicted(mock_event); + EXPECT_EQ(0, getTestAccessor().bodyMapSize()); +} + +TEST_F(HazelcastDividedCacheTest, AbortInsertionIfKeyIsLocked) { + const std::string RequestPath("/only/one/must/insert"); + + LookupContextPtr lookup_context1 = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // Insertion starts for lookup_context1. + InsertContextPtr insert_context = cache_->makeInsertContext(std::move(lookup_context1)); + insert_context->insertHeaders(getResponseHeaders(), false); + auto context1_insert = [&insert_context](std::string body, bool end_stream) { + insert_context->insertBody( + Buffer::OwnedImpl(body), [](bool) {}, end_stream); + }; + context1_insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h'), false); + context1_insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h'), false); + // Insertion has not finished for lookup_context1 yet. + + std::thread t1([&] { + // If the second lookup and insertion would not be performed in a separate thread, + // it will acquire the lock even if it's already locked. This is because the key + // locks on Hazelcast IMap are re-entrant. A locked key can be acquired by the same + // thread again and again based on its pid. Using a different thread here simulates + // another request that yields cache insertion. + LookupContextPtr lookup_context2 = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + insert(move(lookup_context2), getResponseHeaders(), "ignored"); + }); + t1.join(); + + context1_insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h'), true); + EXPECT_TRUE(expectLookupSuccessWithFullBody( + lookup(RequestPath).get(), std::string(HazelcastTestUtil::TEST_PARTITION_SIZE * 3, 'h'))); +} + +TEST_F(HazelcastDividedCacheTest, MissLookupOnVersionMismatch) { + const std::string RequestPath1("/miss/on/version/mismatch"); + + LookupContextPtr lookup_context = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + uint64_t variant_key_hash = + static_cast(*lookup_context).variantKeyHash(); + + const std::string Body(HazelcastTestUtil::TEST_PARTITION_SIZE * 2, 'h'); + insert(move(lookup_context), getResponseHeaders(), Body); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath1).get(), Body)); + + // Change version of the second partition. + auto body2 = cache_->getBody(variant_key_hash, 1); + EXPECT_NE(body2, nullptr); + body2->version(body2->version() + 1); + cache_->putBody(variant_key_hash, 1, *body2); + + // Change happened in the second partition. Lookup to the first one should be successful. + lookup_context = lookup(RequestPath1); + std::string partition1 = getBody(*lookup_context, 0, HazelcastTestUtil::TEST_PARTITION_SIZE); + EXPECT_EQ(partition1, std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h')); + + std::string fullBody = getBody(*lookup_context, 0, HazelcastTestUtil::TEST_PARTITION_SIZE * 2); + EXPECT_EQ(fullBody, HazelcastTestUtil::abortedBodyResponse()); + + // Clean up must be performed for malformed entries. + EXPECT_EQ(0, getTestAccessor().bodyMapSize()); + EXPECT_EQ(0, getTestAccessor().headerMapSize()); +} + +TEST_F(HazelcastDividedCacheTest, MissDividedLookupOnDifferentKey) { + const std::string RequestPath("/miss/on/different/key"); + + LookupContextPtr lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + uint64_t variant_key_hash = + static_cast(*lookup_context).variantKeyHash(); + + const std::string Body("hazelcast"); + insert(move(lookup_context), getResponseHeaders(), Body); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup(RequestPath).get(), Body)); + + // Manipulate the cache entry directly. Cache is not aware of that. + // The cached key will not be the same with the created one by filter. + auto header = cache_->getHeader(variant_key_hash); + Key modified = header->variantKey(); + modified.add_custom_fields("custom1"); + modified.add_custom_fields("custom2"); + header->variantKey(std::move(modified)); + cache_->putHeader(variant_key_hash, *header); + + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // New entry insertion should be aborted and not override the existing one with the + // same hash key. This scenario is possible if there is a hash collision. No eviction + // or clean up is expected. Since overriding an entry is prevented in this case. + InsertContextPtr insert_context = cache_->makeInsertContext(std::move(lookup_context)); + insert_context->insertHeaders(getResponseHeaders(), false); + insert_context->insertBody( + Buffer::OwnedImpl(Body), [](bool ready) { EXPECT_FALSE(ready); }, true); + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + EXPECT_EQ(1, getTestAccessor().headerMapSize()); + + auto modified_header = cache_->getHeader(variant_key_hash); + EXPECT_EQ(*header, *modified_header); +} + +TEST_F(HazelcastDividedCacheTest, CleanUpCachedResponseOnMissingBody) { + const std::string RequestPath1("/clean/up/on/missing/body"); + LookupContextPtr lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + uint64_t variant_key_hash = + static_cast(*lookup_context1).variantKeyHash(); + + const std::string Body = std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h') + + std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'z') + + std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'c'); + + insert(move(lookup_context1), getResponseHeaders(), Body); + lookup_context1 = lookup(RequestPath1); + + // Response is cached with the following pattern: + // variant_key_hash -> HeaderEntry (in header map) + // variant_key_hash "0" -> Body1 (in body map) + // variant_key_hash "1" -> Body2 (in body map) + // variant_key_hash "2" -> Body3 (in body map) + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context1.get(), Body)); + + getTestAccessor().removeBody(orderedMapKey(variant_key_hash, 1)); // evict Body2. + + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + + // Lookup for Body1 is OK. + lookup_context1->getBody({0, HazelcastTestUtil::TEST_PARTITION_SIZE * 3}, + [](Buffer::InstancePtr&& data) { EXPECT_NE(data, nullptr); }); + + { + std::thread t1([&] { + // If another thread locks the key, then the current one should not perform + // clean up. The lock here will serve the purpose. + EXPECT_TRUE(cache_->tryLock(variant_key_hash)); + }); + t1.join(); + + // Lookup for Body2 must fail and trigger clean up. But due to locked key (might + // be acquired by another clean up process for this entry), clean up must do no-op. + lookup_context1->getBody( + {HazelcastTestUtil::TEST_PARTITION_SIZE, HazelcastTestUtil::TEST_PARTITION_SIZE * 3}, + [](Buffer::InstancePtr&& data) { EXPECT_EQ(data, nullptr); }); + + EXPECT_NE(0, getTestAccessor().bodyMapSize()); // clean up is not performed. + + cache_->unlock(variant_key_hash); + } + + { + // Clean up must be aborted when header versions are mismatched. + // This prevents clean up operation for wrong entries. + auto header = cache_->getHeader(variant_key_hash); + int32_t original_version = header->version(); + header->version(original_version - 1); + cache_->putHeader(variant_key_hash, *header); + + lookup_context1->getBody( + {HazelcastTestUtil::TEST_PARTITION_SIZE, HazelcastTestUtil::TEST_PARTITION_SIZE * 3}, + [](Buffer::InstancePtr&& data) { EXPECT_EQ(data, nullptr); }); + + EXPECT_NE(0, getTestAccessor().bodyMapSize()); + + header->version(original_version); + cache_->putHeader(variant_key_hash, *header); + } + + { + // Clean up must be performed after body miss. + lookup_context1->getBody( + {HazelcastTestUtil::TEST_PARTITION_SIZE, HazelcastTestUtil::TEST_PARTITION_SIZE * 3}, + [](Buffer::InstancePtr&& data) { EXPECT_EQ(data, nullptr); }); + + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // Assert clean up + EXPECT_EQ(0, getTestAccessor().bodyMapSize()); + EXPECT_EQ(0, getTestAccessor().headerMapSize()); + } + + // Cache must handle the connection failure during clean up. + getTestAccessor().failOnLock(); + cache_->onMissingBody(0, 0, 0); // HazelcastClientOfflineException + cache_->onMissingBody(0, 0, 0); // std::exception +} + +TEST_F(HazelcastDividedCacheTest, NotCreateBodyOnHeaderOnlyResponse) { + auto headerOnlyTest = [this](std::string path, bool use_empty_body) { + LookupContextPtr lookup_context = lookup(path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + InsertContextPtr insert_context = cache_->makeInsertContext(std::move(lookup_context)); + insert_context->insertHeaders(getResponseHeaders(), !use_empty_body); + if (use_empty_body) { + insert_context->insertBody( + Buffer::OwnedImpl(""), [](bool) {}, true); + } + lookup(path); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + EXPECT_EQ(0, lookup_result_.content_length_); + }; + + // This will pass end_stream = true during header insertion. + headerOnlyTest("/header/only/response", false); + + // This will pass end_stream = false during header insertion, + // then empty body for body insertion. + headerOnlyTest("/empty/body/response", true); + + EXPECT_EQ(0, getTestAccessor().bodyMapSize()); + EXPECT_EQ(2, getTestAccessor().headerMapSize()); +} + +TEST_F(HazelcastDividedCacheTest, AbortDividedOperationsWhenOffline) { + // Operations are arranged to test all exception using Local Test Accessor. + // Changing the order might not cause test to fail but to uncover some exceptions. + { + const std::string RequestPath("/connection/lost/after/insertion"); + LookupContextPtr lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body(HazelcastTestUtil::TEST_PARTITION_SIZE, 's'); + insert(move(lookup_context), getResponseHeaders(), Body); + lookup_context = lookup(RequestPath); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context.get(), Body)); + + getTestAccessor().dropConnection(); + + // std::exception case. + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // HazelcastClientOfflineException case. + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // OperationTimeoutException case. + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + insert(move(lookup_context), getResponseHeaders(), Body); + + getTestAccessor().restoreConnection(); + + lookup_context = lookup(RequestPath); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context.get(), Body)); + } + + { + const std::string RequestPath("/connection/lost/during/insertion"); + InsertContextPtr insert_context = cache_->makeInsertContext(lookup(RequestPath)); + insert_context->insertHeaders(getResponseHeaders(), false); + auto insert = [&insert_context](std::string body, bool end_stream) { + insert_context->insertBody( + Buffer::OwnedImpl(body), [](bool) {}, end_stream); + }; + + insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'h'), false); + insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'z'), false); + + getTestAccessor().dropConnection(); + + // testing std::exception case. + insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 'c'), true); + // testing HazelcastClientOfflineException case. + insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 's'), true); + // testing OperationTimeoutException case. + insert(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE, 't'), true); + + LookupContextPtr lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + getTestAccessor().restoreConnection(); + + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + } +} + +TEST_F(HazelcastDividedCacheTest, FailDuringLock) { + // Tests the case when header lookup is performed without exception but tryLock for + // the insertion permission is failed. Operations are arranged to test all exception + // using Local Test Accessor. Changing the order might not cause test to fail but + // to uncover some exceptions. + const std::string RequestPath("/failed/during/try/lock"); + uint64_t variant_key_hash = + static_cast(*lookup(RequestPath)).variantKeyHash(); + + std::thread t1([&] { + // To make this test compatible with remote accessor, the key is locked here + // explicitly. This behavior will cause tryLock to return false for further + // trials. Hence the lookups below will throw exception for local cache and + // return false for remote cache. This has no effect on local test but lack of + // this locking causes remote test to fail. Notice that if this locking would not + // be performed by a different thread, following insertions in this thread would + // be able to lock the key again and again for remote accessor. + EXPECT_TRUE(cache_->tryLock(variant_key_hash)); + }); + t1.join(); + + // This will cause LocalTestAccessor::tryLock to raise error. + getTestAccessor().failOnLock(); + + insert(lookup(RequestPath), getResponseHeaders(), "aborted"); // std::exception + insert(lookup(RequestPath), getResponseHeaders(), "aborted"); // HazelcastClientOfflineException + insert(lookup(RequestPath), getResponseHeaders(), "aborted"); // OperationTimeoutException + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + cache_->unlock(variant_key_hash); +} + +TEST_F(HazelcastDividedCacheTest, FailDuringBodyLookupWhenHeaderSucceeds) { + // Tests the case when header lookup succeeds but body lookup fails. + const int body_size = HazelcastTestUtil::TEST_PARTITION_SIZE * 2; + const std::string RequestPath("/fail/on/body"); + const std::string Body(body_size, 'h'); + + LookupContextPtr lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + insert(move(lookup_context), getResponseHeaders(), Body); + lookup_context = lookup(RequestPath); + // lookup for header is OK. + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + + // Lookup for Body1 is OK. + lookup_context->getBody({0, HazelcastTestUtil::TEST_PARTITION_SIZE}, + [](Buffer::InstancePtr&& data) { EXPECT_NE(data, nullptr); }); + + getTestAccessor().dropConnection(); + + // Lookup for body should be aborted on HazelcastOffline exception. + lookup_context->getBody({0, body_size}, + [](Buffer::InstancePtr&& data) { EXPECT_EQ(data, nullptr); }); + + // Lookup for body should be aborted on TimeOut exception. + lookup_context->getBody({0, body_size}, + [](Buffer::InstancePtr&& data) { EXPECT_EQ(data, nullptr); }); + + // Lookup for body should be aborted on std::exception. + lookup_context->getBody({0, body_size}, + [](Buffer::InstancePtr&& data) { EXPECT_EQ(data, nullptr); }); + + getTestAccessor().restoreConnection(); + + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context.get(), Body)); +} + +TEST_F(HazelcastDividedCacheTest, AbortInsertionWhenLockLeftover) { + // Happens when a lookup context acquires the lock for insertion but fails to + // unlock before insertion. In such a case, the fail over must be done by + // max.leaseTime of locks set on the Hazelcast server. + const std::string RequestPath1("/connection/lost/on/lock/acquisition/1"); + const std::string RequestPath2("/connection/lost/on/lock/acquisition/2"); + LookupContextPtr lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + LookupContextPtr lookup_context2 = lookup(RequestPath2); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + InsertContextPtr insert_context1 = cache_->makeInsertContext(std::move(lookup_context1)); + InsertContextPtr insert_context2 = cache_->makeInsertContext(std::move(lookup_context2)); + + getTestAccessor().dropConnection(); + insert_context1->insertHeaders(getResponseHeaders(), true); + insert_context2->insertHeaders(getResponseHeaders(), true); + getTestAccessor().restoreConnection(); + + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + insert(move(lookup_context1), getResponseHeaders(), ""); + // insertion fails since the key is still locked. + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + lookup_context2 = lookup(RequestPath2); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + insert(move(lookup_context2), getResponseHeaders(), ""); + // insertion fails since the key is still locked. + lookup_context2 = lookup(RequestPath2); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_entry_serialization_test.cc b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_entry_serialization_test.cc new file mode 100644 index 0000000000000..741dac9f44b33 --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_entry_serialization_test.cc @@ -0,0 +1,110 @@ +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_cache_entry.h" + +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "hazelcast/client/SerializationConfig.h" +#include "hazelcast/client/serialization/pimpl/SerializationService.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +using hazelcast::client::serialization::ObjectDataInput; +using hazelcast::client::serialization::ObjectDataOutput; +using hazelcast::client::serialization::pimpl::DataInput; +using hazelcast::client::serialization::pimpl::DataOutput; +using hazelcast::client::serialization::pimpl::SerializationService; + +class SerializationTest : public testing::Test { +protected: + template std::vector serialize(const T& deserialized) { + DataOutput data_output; + ObjectDataOutput object_data_output(data_output, nullptr); + deserialized.writeData(object_data_output); + return *object_data_output.toByteArray(); + } + + template T deserialize(const std::vector& serialized) { + T object; + hazelcast::client::SerializationConfig serializationConfig; + SerializationService serializationService(serializationConfig); + DataInput data_input(serialized); + ObjectDataInput objectDataInput(data_input, serializationService.getSerializerHolder()); + object.readData(objectDataInput); + return object; + } + + HazelcastHeaderEntry createTestHeader() { + auto headers = Http::ResponseHeaderMapPtr{ + new Http::TestResponseHeaderMapImpl{{"cache-control", "public, max-age=3600"}}}; + Key key; + key.set_cluster_name("some_cluster"); + key.set_host("some_host"); + key.set_path("some_path"); + key.set_query("some_query"); + key.set_clear_http(true); + return HazelcastHeaderEntry(std::move(headers), std::move(key), 2008, 2020); + } + + HazelcastBodyEntry createTestBody() { + return HazelcastBodyEntry( + std::vector({'h', 'a', 'z', 'e', 'l', 'c', 'a', 's', 't'}), 2008); + } + + HazelcastResponseEntry createTestResponse() { + return HazelcastResponseEntry(createTestHeader(), createTestBody()); + } +}; + +TEST_F(SerializationTest, HeaderEntry) { + HazelcastHeaderEntry original_header = createTestHeader(); + auto serialized = serialize(original_header); + HazelcastHeaderEntry new_header = deserialize(serialized); + EXPECT_EQ(original_header, new_header); +} + +TEST_F(SerializationTest, BodyEntry) { + HazelcastBodyEntry original_body = createTestBody(); + auto serialized = serialize(original_body); + HazelcastBodyEntry new_body = deserialize(serialized); + EXPECT_EQ(original_body, new_body); +} + +TEST_F(SerializationTest, ResponseEntry) { + HazelcastResponseEntry original_response = createTestResponse(); + auto serialized = serialize(original_response); + HazelcastResponseEntry new_response = deserialize(serialized); + EXPECT_EQ(original_response, new_response); +} + +TEST_F(SerializationTest, SerializerId) { + HazelcastHeaderEntry header; + HazelcastBodyEntry body; + HazelcastResponseEntry response; + + EXPECT_EQ(header.getClassId(), HAZELCAST_HEADER_TYPE_ID); + EXPECT_EQ(body.getClassId(), HAZELCAST_BODY_TYPE_ID); + EXPECT_EQ(response.getClassId(), HAZELCAST_RESPONSE_TYPE_ID); + + EXPECT_EQ(header.getFactoryId(), HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID); + EXPECT_EQ(body.getFactoryId(), HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID); + EXPECT_EQ(response.getFactoryId(), HAZELCAST_ENTRY_SERIALIZER_FACTORY_ID); +} + +TEST_F(SerializationTest, SerializableFactoryTest) { + HazelcastCacheEntrySerializableFactory factory; + EXPECT_EQ(HAZELCAST_HEADER_TYPE_ID, factory.create(HAZELCAST_HEADER_TYPE_ID)->getClassId()); + EXPECT_EQ(HAZELCAST_BODY_TYPE_ID, factory.create(HAZELCAST_BODY_TYPE_ID)->getClassId()); + EXPECT_EQ(HAZELCAST_RESPONSE_TYPE_ID, factory.create(HAZELCAST_RESPONSE_TYPE_ID)->getClassId()); + EXPECT_EQ(nullptr, factory.create(0).get()); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_unified_cache_test.cc b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_unified_cache_test.cc new file mode 100644 index 0000000000000..2d75e94cbf492 --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/hazelcast_unified_cache_test.cc @@ -0,0 +1,185 @@ +#include "envoy/registry/registry.h" + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_context.h" + +#include "test/extensions/filters/http/cache/hazelcast_http_cache/test_util.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +/** + * Tests for UNIFIED cache mode. + */ +class HazelcastUnifiedCacheTest : public HazelcastHttpCacheTestBase { + void SetUp() override { + HazelcastHttpCacheConfig typed_config = HazelcastTestUtil::getTestTypedConfig(true); + envoy::extensions::filters::http::cache::v3alpha::CacheConfig cache_config = + HazelcastTestUtil::getTestCacheConfig(); + cache_ = std::make_unique(std::move(typed_config), cache_config); + // To test the cache with a real Hazelcast instance, use remote test accessor. + // cache_->start(HazelcastTestUtil::getTestRemoteAccessor(*cache_)); + cache_->start(std::make_unique()); + getTestAccessor().clearMaps(); + } +}; + +TEST_F(HazelcastUnifiedCacheTest, AbortUnifiedInsertionWhenMaxSizeReached) { + const std::string RequestPath("/abort/when/max/size/reached"); + InsertContextPtr insert_context = cache_->makeInsertContext(lookup(RequestPath)); + insert_context->insertHeaders(getResponseHeaders(), false); + bool ready_for_next = true; + while (ready_for_next) { + insert_context->insertBody( + Buffer::OwnedImpl(std::string(HazelcastTestUtil::TEST_PARTITION_SIZE / 3, 'h')), + [&](bool ready) { ready_for_next = ready; }, false); + } + + EXPECT_TRUE(expectLookupSuccessWithFullBody( + lookup(RequestPath).get(), std::string(HazelcastTestUtil::TEST_MAX_BODY_SIZE, 'h'))); +} + +TEST_F(HazelcastUnifiedCacheTest, AllowOverrideExistingResponse) { + const std::string RequestPath("/allow/override/unified/response"); + + LookupContextPtr lookup_context1 = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + LookupContextPtr lookup_context2 = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body1("hazelcast-first"); + const std::string Body2("hazelcast-second"); + + insert(move(lookup_context1), getResponseHeaders(), Body1); + lookup_context1 = lookup(RequestPath); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context1.get(), Body1)); + + insert(move(lookup_context2), getResponseHeaders(), Body2); + lookup_context2 = lookup(RequestPath); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context2.get(), Body2)); +} + +TEST_F(HazelcastUnifiedCacheTest, UnifiedHeaderOnlyResponse) { + InsertContextPtr insert_context = cache_->makeInsertContext(lookup("/header/only")); + insert_context->insertHeaders(getResponseHeaders(), true); + LookupContextPtr lookup_context = lookup("/header/only"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + EXPECT_EQ(0, lookup_result_.content_length_); +} + +TEST_F(HazelcastUnifiedCacheTest, MissUnifiedLookupOnDifferentKey) { + const std::string RequestPath("/miss/on/different/key"); + + LookupContextPtr lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + uint64_t variant_key_hash = + static_cast(*lookup_context).variantKeyHash(); + + const std::string Body("hazelcast"); + insert(move(lookup_context), getResponseHeaders(), Body); + lookup_context = lookup(RequestPath); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context.get(), Body)); + + // Manipulate the cache entry directly. Cache is not aware of that. + // The cached key will not be the same with the created one by filter. + auto response = cache_->getResponse(variant_key_hash); + Key modified = response->header().variantKey(); + modified.add_custom_fields("custom1"); + modified.add_custom_fields("custom2"); + response->header().variantKey(std::move(modified)); + getTestAccessor().insertResponse(mapKey(variant_key_hash), *response); + + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // New entry insertion should be aborted and not override the existing one with the + // same hash key. This scenario is possible if there is a hash collision. No eviction + // or clean up is expected. Since overriding an entry is prevented in this case. + InsertContextPtr insert_context = cache_->makeInsertContext(std::move(lookup_context)); + insert_context->insertHeaders(getResponseHeaders(), false); + insert_context->insertBody( + Buffer::OwnedImpl(Body), [](bool ready) { EXPECT_FALSE(ready); }, true); + lookup_context = lookup(RequestPath); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + EXPECT_EQ(1, getTestAccessor().responseMapSize()); +} + +TEST_F(HazelcastUnifiedCacheTest, AbortUnifiedOperationsWhenOffline) { + const std::string RequestPath1("/online/"); + LookupContextPtr lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body(HazelcastTestUtil::TEST_PARTITION_SIZE, 's'); + insert(move(lookup_context1), getResponseHeaders(), Body); + lookup_context1 = lookup(RequestPath1); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context1.get(), Body)); + + // These lookup are not marked as to be aborted since connection is still alive. + // They will be used to test insertion behavior when cache is offline. + LookupContextPtr succeed_lookup1 = lookup(RequestPath1); + LookupContextPtr succeed_lookup2 = lookup(RequestPath1); + LookupContextPtr succeed_lookup3 = lookup(RequestPath1); + + getTestAccessor().dropConnection(); + + // UnifiedLookupContext::getHeaders when HazelcastClientOfflineException is thrown. + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + // lookup has marked insertion to be aborted. Hence the following should do no-op. + insert(move(lookup_context1), getResponseHeaders(), Body); + + // UnifiedLookupContext::getHeaders when OperationTimeoutException is thrown. + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // UnifiedLookupContext::getHeaders when std::exception is thrown. + lookup_context1 = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + // UnifiedInsertContext::insertResponse when HazelcastClientOfflineException is thrown. + insert(move(succeed_lookup1), getResponseHeaders(), Body); + // UnifiedInsertContext::insertResponse when OperationTimeoutException is thrown. + insert(move(succeed_lookup2), getResponseHeaders(), Body); + // UnifiedInsertContext::insertResponse when std::exception is thrown. + insert(move(succeed_lookup3), getResponseHeaders(), Body); + + getTestAccessor().restoreConnection(); + + lookup_context1 = lookup(RequestPath1); + EXPECT_TRUE(expectLookupSuccessWithFullBody(lookup_context1.get(), Body)); +} + +TEST_F(HazelcastUnifiedCacheTest, CoverRemoteOperations) { + // Since not a real Hazelcast instance is used during tests, + // in order to keep the coverage in a desired level this test + // covers the calls made to Hazelcast cluster. Otherwise the coverage + // stays at 90% without including the remote calls. + HazelcastClusterAccessor accessor(*cache_, ClientConfig(), "coverage", 10); + const std::string info = accessor.startInfo(); + EXPECT_NE(info.find("profile: UNIFIED"), std::string::npos); + EXPECT_NE(info.find(absl::StrFormat("Max body size: %d", cache_->maxBodyBytes())), + std::string::npos); + EXPECT_FALSE(accessor.isRunning()); + EXPECT_STREQ("", accessor.clusterName().c_str()); + EXPECT_THROW(accessor.putHeader(1, HazelcastHeaderEntry()), EnvoyException); + EXPECT_THROW(accessor.putBody("1", HazelcastBodyEntry()), EnvoyException); + EXPECT_THROW(accessor.putResponse(1, HazelcastResponseEntry()), EnvoyException); + EXPECT_THROW(accessor.getHeader(1), EnvoyException); + EXPECT_THROW(accessor.getBody("1"), EnvoyException); + EXPECT_THROW(accessor.getResponse(1), EnvoyException); + EXPECT_THROW(accessor.removeBodyAsync("1"), EnvoyException); + EXPECT_THROW(accessor.removeHeader(1), EnvoyException); + EXPECT_THROW(accessor.tryLock(1, true), EnvoyException); + EXPECT_THROW(accessor.unlock(1, true), EnvoyException); + EXPECT_THROW(accessor.unlock(1, false), EnvoyException); + accessor.disconnect(); +} + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/test_accessors.h b/test/extensions/filters/http/cache/hazelcast_http_cache/test_accessors.h new file mode 100644 index 0000000000000..5effc914cdefa --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/test_accessors.h @@ -0,0 +1,246 @@ +#pragma once + +#include "extensions/filters/http/cache/hazelcast_http_cache/hazelcast_http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +/** + * Abstraction for the accessors used in tests. + * + * Contains pure functions to obtain storage information, modify the storage and + * change accessor behavior directly. + */ +class TestAccessor : public virtual StorageAccessor { +public: + TestAccessor() = default; + + virtual void clearMaps() PURE; + virtual void dropConnection() PURE; + virtual void restoreConnection() PURE; + + virtual int headerMapSize() PURE; + virtual int bodyMapSize() PURE; + virtual int responseMapSize() PURE; + + virtual void insertResponse(int64_t map_key, const HazelcastResponseEntry& entry) PURE; + virtual void removeBody(const std::string& map_key) PURE; + + virtual void failOnLock() PURE; + + virtual ~TestAccessor() = default; +}; + +/** + * Testable Hazelcast cluster accessor. + * + * @note A Hazelcast instance must be up during tests when this accessor is used. + */ +class RemoteTestAccessor : public TestAccessor, public HazelcastClusterAccessor { +public: + RemoteTestAccessor(HazelcastHttpCache& cache, ClientConfig&& client_config, + const std::string& app_prefix, const uint64_t partition_size) + : HazelcastClusterAccessor(cache, std::move(client_config), app_prefix, partition_size){}; + + void clearMaps() override { + getResponseMap().clear(); + getBodyMap().clear(); + getHeaderMap().clear(); + } + + void dropConnection() override { disconnect(); } + + void restoreConnection() override { connect(); } + + int headerMapSize() override { return getHeaderMap().size(); } + + int bodyMapSize() override { return getBodyMap().size(); } + + int responseMapSize() override { return getResponseMap().size(); } + + void removeBody(const std::string& map_key) override { getBodyMap().remove(map_key); } + + void insertResponse(int64_t map_key, const HazelcastResponseEntry& entry) override { + getResponseMap().put(map_key, entry); + } + + void failOnLock() override {} // Required for local accessor only. +}; + +/** + * Testable local storage accessor. + * + * @note This accessor does not use any Hazelcast instance during tests. + * Instead, it simulates Hazelcast instance with local storage. + */ +class LocalTestAccessor : public TestAccessor { +public: + LocalTestAccessor() = default; + + // TestAccessor + void clearMaps() override { + header_map_.clear(); + body_map_.clear(); + response_map_.clear(); + } + + void dropConnection() override { disconnect(); } + + void restoreConnection() override { connect(); } + + int headerMapSize() override { return header_map_.size(); } + + int bodyMapSize() override { return body_map_.size(); } + + int responseMapSize() override { return response_map_.size(); } + + void insertResponse(int64_t map_key, const HazelcastResponseEntry& entry) override { + checkConnection(); + response_map_[map_key] = HazelcastResponsePtr(new HazelcastResponseEntry(entry)); + } + + void removeBody(const std::string& map_key) override { + checkConnection(); + removeBodyAsync(map_key); + } + + // StorageAccessor + void putHeader(const int64_t map_key, const HazelcastHeaderEntry& value) override { + checkConnection(); + header_map_[map_key] = HazelcastHeaderPtr(new HazelcastHeaderEntry(value)); + } + + void putBody(const std::string& map_key, const HazelcastBodyEntry& value) override { + checkConnection(); + body_map_[map_key] = HazelcastBodyPtr(new HazelcastBodyEntry(value)); + } + + void putResponse(const int64_t map_key, const HazelcastResponseEntry& value) override { + insertResponse(map_key, value); + } + + HazelcastHeaderPtr getHeader(const int64_t map_key) override { + checkConnection(); + auto result = header_map_.find(map_key); + if (result != header_map_.end()) { + // New objects are created during deserialization. Hence not returning + // the original one here. + return HazelcastHeaderPtr(new HazelcastHeaderEntry(*result->second)); + } else { + return nullptr; + } + } + + HazelcastBodyPtr getBody(const std::string& map_key) override { + checkConnection(); + auto result = body_map_.find(map_key); + if (result != body_map_.end()) { + return HazelcastBodyPtr(new HazelcastBodyEntry(*result->second)); + } else { + return nullptr; + } + } + + HazelcastResponsePtr getResponse(const int64_t map_key) override { + checkConnection(); + auto result = response_map_.find(map_key); + if (result != response_map_.end()) { + return HazelcastResponsePtr(new HazelcastResponseEntry(*result->second)); + } else { + return nullptr; + } + } + + void removeBodyAsync(const std::string& map_key) override { + checkConnection(); + body_map_.erase(map_key); + } + + void removeHeader(const int64_t map_key) override { + checkConnection(); + header_map_.erase(map_key); + } + + bool tryLock(const int64_t map_key, bool unified) override { + checkConnection(fail_on_lock_); + if (unified) { + bool locked = std::find(response_locks_.begin(), response_locks_.end(), map_key) != + response_locks_.end(); + if (locked) { + return false; + } else { + response_locks_.push_back(map_key); + return true; + } + } else { + bool locked = + std::find(header_locks_.begin(), header_locks_.end(), map_key) != header_locks_.end(); + if (locked) { + return false; + } else { + header_locks_.push_back(map_key); + return true; + } + } + } + + void unlock(const int64_t map_key, bool unified) override { + checkConnection(); + if (unified) { + response_locks_.erase(std::remove(response_locks_.begin(), response_locks_.end(), map_key), + response_locks_.end()); + } else { + header_locks_.erase(std::remove(header_locks_.begin(), header_locks_.end(), map_key), + header_locks_.end()); + } + } + + bool isRunning() const override { return connected_; } + + std::string clusterName() const override { return "LocalTestAccessor"; } + + std::string startInfo() const override { return ""; } + + void connect() override { connected_ = true; } + + void disconnect() override { connected_ = false; } + + void failOnLock() override { fail_on_lock_ = true; } + +private: + void checkConnection(bool force_fail = false) { + if (!connected_ || force_fail) { + // Different exceptions are thrown for consecutive fails to test other catch behaviors. + switch (exception_counter_++ % 3) { + case 0: + throw std::exception(); + case 1: + throw hazelcast::client::exception::HazelcastClientOfflineException( + "LocalTestAccessor::checkConnection", "Hazelcast client is offline"); + default: + throw hazelcast::client::exception::OperationTimeoutException( + "LocalTestAccessor::checkConnection", "Operation timed out"); + } + } + } + + std::unordered_map header_map_; + std::unordered_map body_map_; + std::unordered_map response_map_; + + std::vector header_locks_; + std::vector response_locks_; + + bool connected_ = false; + bool fail_on_lock_ = false; + int exception_counter_ = 0; +}; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/hazelcast_http_cache/test_util.h b/test/extensions/filters/http/cache/hazelcast_http_cache/test_util.h new file mode 100644 index 0000000000000..a60b583634733 --- /dev/null +++ b/test/extensions/filters/http/cache/hazelcast_http_cache/test_util.h @@ -0,0 +1,188 @@ +#pragma once + +#include "extensions/filters/http/cache/hazelcast_http_cache/util.h" + +#include "test/extensions/filters/http/cache/hazelcast_http_cache/test_accessors.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace HazelcastHttpCache { + +class HazelcastTestUtil { +public: + static constexpr int TEST_PARTITION_SIZE = 10; + static constexpr int TEST_MAX_BODY_SIZE = TEST_PARTITION_SIZE * 20; + + static Runtime::RandomGeneratorImpl& randomGenerator() { + static Runtime::RandomGeneratorImpl rand; + return rand; + } + + static const std::string& abortedBodyResponse() { + static std::string response("NULL_BODY"); + return response; + } + + static envoy::extensions::filters::http::cache::v3alpha::CacheConfig getTestCacheConfig() { + envoy::extensions::filters::http::cache::v3alpha::CacheConfig cache_config; + cache_config.set_max_body_bytes(TEST_MAX_BODY_SIZE); + return cache_config; + } + + static HazelcastHttpCacheConfig getTestTypedConfig(bool unified) { + HazelcastHttpCacheConfig typed_config; + typed_config.set_group_name("dev"); + typed_config.set_group_password("dev-pass"); + envoy::config::core::v3::SocketAddress* member_address = typed_config.add_addresses(); + member_address->set_address("127.0.0.1"); + member_address->set_port_value(5701); + typed_config.set_invocation_timeout(1); + typed_config.set_body_partition_size(TEST_PARTITION_SIZE); + // During parallel tests, if caches do not have different prefixes, the entries + // and hence the results will be different than the expected. + typed_config.set_app_prefix(randomGenerator().uuid()); + typed_config.set_unified(unified); + return typed_config; + } + + static StorageAccessorPtr getTestRemoteAccessor(HazelcastHttpCache& cache) { + HazelcastHttpCacheConfig typed_config = getTestTypedConfig(cache.unified()); + ClientConfig client_config = ConfigUtil::getClientConfig(typed_config); + StorageAccessorPtr accessor = std::make_unique( + cache, std::move(client_config), cache.prefix(), cache.bodySizePerEntry()); + return accessor; + } + + static void setRequestHeaders(Http::TestRequestHeaderMapImpl& headers) { + headers.setMethod("GET"); + headers.setHost("hazelcast.com"); + headers.setForwardedProto("https"); + headers.setCacheControl("max-age=3600"); + } +}; + +/** + * The base test environment for DIVIDED and UNIFIED cache mode tests. + * + * A similar environment to SimpleHttpCacheTest is applied and + * some functions & fields are derived directly. + * + */ +class HazelcastHttpCacheTestBase : public testing::Test { +protected: + HazelcastHttpCacheTestBase() { HazelcastTestUtil::setRequestHeaders(request_headers_); } + + int64_t mapKey(const uint64_t key_hash) { return cache_->mapKey(key_hash); } + + TestAccessor& getTestAccessor() { return dynamic_cast(*cache_->accessor_); } + + std::string orderedMapKey(const uint64_t key_hash, const uint64_t order) { + return cache_->orderedMapKey(key_hash, order); + } + + // Makes getBody requests until requested range is satisfied. + // Returns the body on success; HazelcastTestUtil::abortedBodyResponse() on + // abortion by cache. + std::string getBody(LookupContext& context, uint64_t start, uint64_t end) { + std::string full_body, body_chunk; + uint64_t offset = start; + bool aborted = false; + while (full_body.length() != end - start) { + if (aborted) { + return HazelcastTestUtil::abortedBodyResponse(); + } + AdjustedByteRange range(offset, end); + context.getBody(range, + [&aborted, &body_chunk, &offset, &full_body](Buffer::InstancePtr&& data) { + if (data) { + body_chunk = data->toString(); + full_body.append(body_chunk); + offset += body_chunk.length(); + } else { + aborted = true; + } + }); + } + return full_body; + } + + Http::TestResponseHeaderMapImpl getResponseHeaders() { + return Http::TestResponseHeaderMapImpl{{"date", formatter_.fromTime(current_time_)}, + {"cache-control", "public, max-age=3600"}}; + } + + /// from SimpleHttpCacheTest + + LookupContextPtr lookup(absl::string_view request_path) { + LookupRequest request = makeLookupRequest(request_path); + LookupContextPtr context = cache_->makeLookupContext(std::move(request)); + context->getHeaders([this](LookupResult&& result) { lookup_result_ = std::move(result); }); + return context; + } + + void insert(LookupContextPtr lookup, const Http::TestResponseHeaderMapImpl& response_headers, + const absl::string_view response_body) { + InsertContextPtr insert_context = cache_->makeInsertContext(move(lookup)); + insert_context->insertHeaders(response_headers, response_body == nullptr); + if (response_body == nullptr) { + return; + } + insert_context->insertBody(Buffer::OwnedImpl(response_body), nullptr, true); + } + + void insert(absl::string_view request_path, + const Http::TestResponseHeaderMapImpl& response_headers, + const absl::string_view response_body) { + insert(lookup(request_path), response_headers, response_body); + } + + LookupRequest makeLookupRequest(absl::string_view request_path) { + request_headers_.setPath(request_path); + return LookupRequest(request_headers_, current_time_); + } + + AssertionResult expectLookupSuccessWithFullBody(LookupContext* lookup_context, + absl::string_view body) { + if (lookup_result_.content_length_ != body.size()) { + return AssertionFailure() << "Expected: lookup_result_.content_length_" + " == " + << body.size() << "\n Actual: " << lookup_result_.content_length_; + } + // From SimpleHttpCacheTest + if (lookup_result_.cache_entry_status_ != CacheEntryStatus::Ok) { + return AssertionFailure() << "Expected: lookup_result_.cache_entry_status" + " == CacheEntryStatus::Ok\n Actual: " + << lookup_result_.cache_entry_status_; + } + if (!lookup_result_.headers_) { + return AssertionFailure() << "Expected nonnull lookup_result_.headers"; + } + if (!lookup_context) { + return AssertionFailure() << "Expected nonnull lookup_context"; + } + const std::string actual_body = getBody(*lookup_context, 0, body.size()); + if (body != actual_body) { + return AssertionFailure() << "Expected body == " << body << "\n Actual: " << actual_body; + } + return AssertionSuccess(); + } + + std::unique_ptr cache_; + LookupResult lookup_result_; + Http::TestRequestHeaderMapImpl request_headers_; + Event::SimulatedTimeSystem time_source_; + SystemTime current_time_ = time_source_.systemTime(); + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; +}; + +} // namespace HazelcastHttpCache +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/tools/spelling/spelling_dictionary.txt b/tools/spelling/spelling_dictionary.txt index de6d46a158758..9df28a6083769 100644 --- a/tools/spelling/spelling_dictionary.txt +++ b/tools/spelling/spelling_dictionary.txt @@ -123,6 +123,7 @@ GSS GTEST GURL Grabbit +HAZELCAST Hashable HC HCM