diff --git a/CODEOWNERS b/CODEOWNERS index b382e8c149fbc..d37dc0856a451 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -102,6 +102,8 @@ extensions/filters/common/original_src @klarose @mattklein123 # HTTP caching extension /*/extensions/filters/http/cache @toddmgreer @ravenblackx @penguingao @mpwarres @capoferro /*/extensions/http/cache/simple_http_cache @toddmgreer @ravenblackx @penguingao @mpwarres @capoferro +/*/extensions/filters/http/cache_v2 @toddmgreer @ravenblackx @penguingao @mpwarres @capoferro +/*/extensions/http/cache_v2/simple_http_cache @toddmgreer @ravenblackx @penguingao @mpwarres @capoferro # AWS common signing components /*/extensions/common/aws @mattklein123 @nbaws @niax # adaptive concurrency limit extension. @@ -245,6 +247,7 @@ extensions/upstreams/tcp @ggreenway @mattklein123 /*/extensions/common/async_files @mattklein123 @ravenblackx /*/extensions/filters/http/file_system_buffer @mattklein123 @ravenblackx /*/extensions/http/cache/file_system_http_cache @ggreenway @ravenblackx +/*/extensions/http/cache_v2/file_system_http_cache @ggreenway @ravenblackx # Google Cloud Platform Authentication Filter /*/extensions/filters/http/gcp_authn @tyxia @yanavlasov # DNS resolution diff --git a/api/BUILD b/api/BUILD index 18014a1fe8043..019f5a4e4287e 100644 --- a/api/BUILD +++ b/api/BUILD @@ -173,6 +173,7 @@ proto_library( "//envoy/extensions/filters/http/basic_auth/v3:pkg", "//envoy/extensions/filters/http/buffer/v3:pkg", "//envoy/extensions/filters/http/cache/v3:pkg", + "//envoy/extensions/filters/http/cache_v2/v3:pkg", "//envoy/extensions/filters/http/cdn_loop/v3:pkg", "//envoy/extensions/filters/http/composite/v3:pkg", "//envoy/extensions/filters/http/compressor/v3:pkg", @@ -273,6 +274,8 @@ proto_library( "//envoy/extensions/health_checkers/thrift/v3:pkg", "//envoy/extensions/http/cache/file_system_http_cache/v3:pkg", "//envoy/extensions/http/cache/simple_http_cache/v3:pkg", + "//envoy/extensions/http/cache_v2/file_system_http_cache/v3:pkg", + "//envoy/extensions/http/cache_v2/simple_http_cache/v3:pkg", "//envoy/extensions/http/custom_response/local_response_policy/v3:pkg", "//envoy/extensions/http/custom_response/redirect_policy/v3:pkg", "//envoy/extensions/http/early_header_mutation/header_mutation/v3:pkg", diff --git a/api/envoy/extensions/filters/http/cache_v2/v3/BUILD b/api/envoy/extensions/filters/http/cache_v2/v3/BUILD new file mode 100644 index 0000000000000..d993dbfdb026c --- /dev/null +++ b/api/envoy/extensions/filters/http/cache_v2/v3/BUILD @@ -0,0 +1,14 @@ +# DO NOT EDIT. This file is generated by tools/proto_format/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/config/route/v3:pkg", + "//envoy/type/matcher/v3:pkg", + "@com_github_cncf_xds//udpa/annotations:pkg", + "@com_github_cncf_xds//xds/annotations/v3:pkg", + ], +) diff --git a/api/envoy/extensions/filters/http/cache_v2/v3/cache.proto b/api/envoy/extensions/filters/http/cache_v2/v3/cache.proto new file mode 100644 index 0000000000000..9a335f55a7392 --- /dev/null +++ b/api/envoy/extensions/filters/http/cache_v2/v3/cache.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.cache_v2.v3; + +import "envoy/config/route/v3/route_components.proto"; +import "envoy/type/matcher/v3/string.proto"; + +import "google/protobuf/any.proto"; +import "google/protobuf/wrappers.proto"; + +import "xds/annotations/v3/status.proto"; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.cache_v2.v3"; +option java_outer_classname = "CacheProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/cache_v2/v3;cache_v2v3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; +option (xds.annotations.v3.file_status).work_in_progress = true; + +// [#protodoc-title: HTTP Cache Filter V2] + +// [#extension: envoy.filters.http.cache_v2] +// [#next-free-field: 8] +message CacheV2Config { + // [#not-implemented-hide:] + // Modifies cache key creation by restricting which parts of the URL are included. + message KeyCreatorParams { + // If true, exclude the URL scheme from the cache key. Set to true if your origins always + // produce the same response for http and https requests. + bool exclude_scheme = 1; + + // If true, exclude the host from the cache key. Set to true if your origins' responses don't + // ever depend on host. + bool exclude_host = 2; + + // If ``query_parameters_included`` is nonempty, only query parameters matched + // by one or more of its matchers are included in the cache key. Any other + // query params will not affect cache lookup. + repeated config.route.v3.QueryParameterMatcher query_parameters_included = 3; + + // If ``query_parameters_excluded`` is nonempty, query parameters matched by one + // or more of its matchers are excluded from the cache key (even if also + // matched by ``query_parameters_included``), and will not affect cache lookup. + repeated config.route.v3.QueryParameterMatcher query_parameters_excluded = 4; + } + + // Config specific to the cache storage implementation. Required unless ``disabled`` + // is true. + // [#extension-category: envoy.http.cache_v2] + google.protobuf.Any typed_config = 1; + + // When true, the cache filter is a no-op filter. + // + // Possible use-cases for this include: + // - Turning a filter on and off with :ref:`ECDS `. + // [#comment: once route-specific overrides are implemented, they are the more likely use-case.] + google.protobuf.BoolValue disabled = 5; + + // [#not-implemented-hide:] + // List of matching rules that defines allowed ``Vary`` headers. + // + // The ``vary`` response header holds a list of header names that affect the + // contents of a response, as described by + // https://httpwg.org/specs/rfc7234.html#caching.negotiated.responses. + // + // During insertion, ``allowed_vary_headers`` acts as a allowlist: if a + // response's ``vary`` header mentions any header names that aren't matched by any rules in + // ``allowed_vary_headers``, that response will not be cached. + // + // During lookup, ``allowed_vary_headers`` controls what request headers will be + // sent to the cache storage implementation. + repeated type.matcher.v3.StringMatcher allowed_vary_headers = 2; + + // [#not-implemented-hide:] + // + // + // Modifies cache key creation by restricting which parts of the URL are included. + KeyCreatorParams key_creator_params = 3; + + // [#not-implemented-hide:] + // + // + // Max body size the cache filter will insert into a cache. 0 means unlimited (though the cache + // storage implementation may have its own limit beyond which it will reject insertions). + uint32 max_body_bytes = 4; + + // By default, a ``cache-control: no-cache`` or ``pragma: no-cache`` header in the request + // causes the cache to validate with its upstream even if the lookup is a hit. Setting this + // to true will ignore these headers. + bool ignore_request_cache_control_header = 6; + + // If this is set, requests sent upstream to populate the cache will go to the + // specified cluster rather than the cluster selected by the vhost and route. + // + // If you have actions to be taken by the router filter - either + // ``upstream_http_filters`` or one of the ``RouteConfiguration`` actions such as + // ``response_headers_to_add`` - then the cache's side-channel going directly to the + // routed cluster will bypass these actions. You can set ``override_upstream_cluster`` + // to an internal listener which duplicates the relevant ``RouteConfiguration``, to + // replicate the desired behavior on the side-channel upstream request issued by the + // cache. + // + // This is a workaround for implementation constraints which it is hoped will at some + // point become unnecessary, then unsupported and this field will be removed. + string override_upstream_cluster = 7; +} diff --git a/api/envoy/extensions/http/cache_v2/file_system_http_cache/v3/BUILD b/api/envoy/extensions/http/cache_v2/file_system_http_cache/v3/BUILD new file mode 100644 index 0000000000000..5b108dcfee6c7 --- /dev/null +++ b/api/envoy/extensions/http/cache_v2/file_system_http_cache/v3/BUILD @@ -0,0 +1,13 @@ +# DO NOT EDIT. This file is generated by tools/proto_format/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/extensions/common/async_files/v3:pkg", + "@com_github_cncf_xds//udpa/annotations:pkg", + "@com_github_cncf_xds//xds/annotations/v3:pkg", + ], +) diff --git a/api/envoy/extensions/http/cache_v2/file_system_http_cache/v3/file_system_http_cache.proto b/api/envoy/extensions/http/cache_v2/file_system_http_cache/v3/file_system_http_cache.proto new file mode 100644 index 0000000000000..cbfc5777222a1 --- /dev/null +++ b/api/envoy/extensions/http/cache_v2/file_system_http_cache/v3/file_system_http_cache.proto @@ -0,0 +1,131 @@ +syntax = "proto3"; + +package envoy.extensions.http.cache_v2.file_system_http_cache.v3; + +import "envoy/extensions/common/async_files/v3/async_file_manager.proto"; + +import "google/protobuf/duration.proto"; +import "google/protobuf/wrappers.proto"; + +import "xds/annotations/v3/status.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.http.cache_v2.file_system_http_cache.v3"; +option java_outer_classname = "FileSystemHttpCacheProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/http/cache_v2/file_system_http_cache/v3;file_system_http_cachev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; +option (xds.annotations.v3.file_status).work_in_progress = true; + +// [#protodoc-title: FileSystemHttpCacheV2Config] +// [#extension: envoy.extensions.http.cache_v2.file_system_http_cache] + +// Configuration for a cache implementation that caches in the local file system. +// +// By default this cache uses a least-recently-used eviction strategy. +// +// For implementation details, see `DESIGN.md `_. +// [#next-free-field: 11] +message FileSystemHttpCacheV2Config { + // Configuration of a manager for how the file system is used asynchronously. + common.async_files.v3.AsyncFileManagerConfig manager_config = 1 + [(validate.rules).message = {required: true}]; + + // Path at which the cache files will be stored. + // + // This also doubles as the unique identifier for a cache, so a cache can be shared + // between different routes, or separate paths can be used to specify separate caches. + // + // If the same ``cache_path`` is used in more than one ``CacheV2Config``, the rest of the + // ``FileSystemHttpCacheV2Config`` must also match, and will refer to the same cache + // instance. + string cache_path = 2 [(validate.rules).string = {min_len: 1}]; + + // The maximum size of the cache in bytes - when reached, cache eviction is triggered. + // + // This is measured as the sum of file sizes, such that it includes headers, trailers, + // and metadata, but does not include e.g. file system overhead and block size padding. + // + // If unset there is no limit except file system failure. + google.protobuf.UInt64Value max_cache_size_bytes = 3; + + // The maximum size of a cache entry in bytes - larger responses will not be cached. + // + // This is measured as the file size for the cache entry, such that it includes + // headers, trailers, and metadata. + // + // If unset there is no limit. + // + // [#not-implemented-hide:] + google.protobuf.UInt64Value max_individual_cache_entry_size_bytes = 4; + + // The maximum number of cache entries - when reached, cache eviction is triggered. + // + // If unset there is no limit. + google.protobuf.UInt64Value max_cache_entry_count = 5; + + // A number of folders into which to subdivide the cache. + // + // Setting this can help with performance in file systems where a large number of inodes + // in a single branch degrades performance. The optimal value in that case would be + // ``sqrt(expected_cache_entry_count)``. + // + // On file systems that perform well with many inodes, the default value of 1 should be used. + // + // [#not-implemented-hide:] + uint32 cache_subdivisions = 6; + + // The amount of the maximum cache size or count to evict when cache eviction is + // triggered. For example, if ``max_cache_size_bytes`` is 10000000 and ``evict_fraction`` + // is 0.2, then when the cache exceeds 10MB, entries will be evicted until the cache size is + // less than or equal to 8MB. + // + // The default value of 0 means when the cache exceeds 10MB, entries will be evicted only + // until the cache is less than or equal to 10MB. + // + // Evicting a larger fraction will mean the eviction thread will run less often (sparing + // CPU load) at the cost of more cache misses due to the extra evicted entries. + // + // [#not-implemented-hide:] + float evict_fraction = 7; + + // The longest amount of time to wait before running a cache eviction pass. An eviction + // pass may not necessarily remove any files, but it will update the cache state to match + // the on-disk state. This can be important if multiple instances are accessing the same + // cache in parallel. (e.g. if two instances each independently added non-overlapping 10MB + // of content to a cache with a 15MB limit, neither instance would be aware that the limit + // was exceeded without this synchronizing pass.) + // + // If an eviction pass has not happened within this duration, the eviction thread will + // be awoken and perform an eviction pass. + // + // If unset, there will be no eviction passes except those triggered by cache limits. + // + // [#not-implemented-hide:] + google.protobuf.Duration max_eviction_period = 8; + + // The shortest amount of time between cache eviction passes. This can be used to reduce + // eviction churn, if your cache max size can be flexible. If a cache eviction pass already + // occurred more recently than this period when another would be triggered, that new + // pass is cancelled. + // + // This means the cache can potentially grow beyond ``max_cache_size_bytes`` by as much as + // can be written within the duration specified. + // + // Generally you would use *either* ``min_eviction_period`` *or* ``evict_fraction`` to + // reduce churn. Both together will work but since they're both aiming for the same goal, + // it's simpler not to. + // + // [#not-implemented-hide:] + google.protobuf.Duration min_eviction_period = 9; + + // If true, and the cache path does not exist, attempt to create the cache path, including + // any missing directories leading up to it. On failure, the config is rejected. + // + // If false, and the cache path does not exist, the config is rejected. + // + // [#not-implemented-hide:] + bool create_cache_path = 10; +} diff --git a/api/envoy/extensions/http/cache_v2/simple_http_cache/v3/BUILD b/api/envoy/extensions/http/cache_v2/simple_http_cache/v3/BUILD new file mode 100644 index 0000000000000..d49202b74ab44 --- /dev/null +++ b/api/envoy/extensions/http/cache_v2/simple_http_cache/v3/BUILD @@ -0,0 +1,12 @@ +# DO NOT EDIT. This file is generated by tools/proto_format/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "@com_github_cncf_xds//udpa/annotations:pkg", + "@com_github_cncf_xds//xds/annotations/v3:pkg", + ], +) diff --git a/api/envoy/extensions/http/cache_v2/simple_http_cache/v3/config.proto b/api/envoy/extensions/http/cache_v2/simple_http_cache/v3/config.proto new file mode 100644 index 0000000000000..9db3757babc61 --- /dev/null +++ b/api/envoy/extensions/http/cache_v2/simple_http_cache/v3/config.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package envoy.extensions.http.cache_v2.simple_http_cache.v3; + +import "xds/annotations/v3/status.proto"; + +import "udpa/annotations/status.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.http.cache_v2.simple_http_cache.v3"; +option java_outer_classname = "ConfigProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/http/cache_v2/simple_http_cache/v3;simple_http_cachev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; +option (xds.annotations.v3.file_status).work_in_progress = true; + +// [#protodoc-title: SimpleHttpCache CacheFilter storage plugin] + +// [#extension: envoy.extensions.http.cache_v2.simple] +message SimpleHttpCacheV2Config { +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index 8b48f43270988..1f5430a262a8e 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -111,6 +111,7 @@ proto_library( "//envoy/extensions/filters/http/basic_auth/v3:pkg", "//envoy/extensions/filters/http/buffer/v3:pkg", "//envoy/extensions/filters/http/cache/v3:pkg", + "//envoy/extensions/filters/http/cache_v2/v3:pkg", "//envoy/extensions/filters/http/cdn_loop/v3:pkg", "//envoy/extensions/filters/http/composite/v3:pkg", "//envoy/extensions/filters/http/compressor/v3:pkg", @@ -211,6 +212,8 @@ proto_library( "//envoy/extensions/health_checkers/thrift/v3:pkg", "//envoy/extensions/http/cache/file_system_http_cache/v3:pkg", "//envoy/extensions/http/cache/simple_http_cache/v3:pkg", + "//envoy/extensions/http/cache_v2/file_system_http_cache/v3:pkg", + "//envoy/extensions/http/cache_v2/simple_http_cache/v3:pkg", "//envoy/extensions/http/custom_response/local_response_policy/v3:pkg", "//envoy/extensions/http/custom_response/redirect_policy/v3:pkg", "//envoy/extensions/http/early_header_mutation/header_mutation/v3:pkg", diff --git a/bazel/repositories.bzl b/bazel/repositories.bzl index d7d1b716041be..2e75080e0fc21 100644 --- a/bazel/repositories.bzl +++ b/bazel/repositories.bzl @@ -7,6 +7,7 @@ PPC_SKIP_TARGETS = ["envoy.string_matcher.lua", "envoy.filters.http.lua", "envoy WINDOWS_SKIP_TARGETS = [ "envoy.extensions.http.cache.file_system_http_cache", + "envoy.extensions.http.cache_v2.file_system_http_cache", "envoy.filters.http.file_system_buffer", "envoy.filters.http.language", "envoy.filters.http.sxg", diff --git a/docs/root/_static/cache-v2-filter-chain.svg b/docs/root/_static/cache-v2-filter-chain.svg new file mode 100644 index 0000000000000..fe54227819ffd --- /dev/null +++ b/docs/root/_static/cache-v2-filter-chain.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/root/_static/cache-v2-filter-internal-listener.svg b/docs/root/_static/cache-v2-filter-internal-listener.svg new file mode 100644 index 0000000000000..be569c60adbaf --- /dev/null +++ b/docs/root/_static/cache-v2-filter-internal-listener.svg @@ -0,0 +1 @@ + diff --git a/docs/root/api-v3/config/config.rst b/docs/root/api-v3/config/config.rst index f12fad766ea91..1467e8f0f5306 100644 --- a/docs/root/api-v3/config/config.rst +++ b/docs/root/api-v3/config/config.rst @@ -22,6 +22,7 @@ Extensions health_check_event_sinks/health_check_event_sinks health_checker/health_checker http/early_header_mutation + http/cache_v2 http/custom_response http/ext_proc http/header_formatters diff --git a/docs/root/api-v3/config/http/cache_v2.rst b/docs/root/api-v3/config/http/cache_v2.rst new file mode 100644 index 0000000000000..4c9a9495891fa --- /dev/null +++ b/docs/root/api-v3/config/http/cache_v2.rst @@ -0,0 +1,8 @@ +HttpCacheV2 cache implementations +================================= + +.. toctree:: + :glob: + :maxdepth: 2 + + ../../extensions/http/cache_v2/*/v3/* diff --git a/docs/root/configuration/http/caches_v2/caches.rst b/docs/root/configuration/http/caches_v2/caches.rst new file mode 100644 index 0000000000000..db0efed3dc9ce --- /dev/null +++ b/docs/root/configuration/http/caches_v2/caches.rst @@ -0,0 +1,9 @@ +.. _config_http_caches_v2: + +HTTP caches +=========== + +.. toctree:: + :maxdepth: 2 + + file_system diff --git a/docs/root/configuration/http/caches_v2/file_system.rst b/docs/root/configuration/http/caches_v2/file_system.rst new file mode 100644 index 0000000000000..93ffd013facbd --- /dev/null +++ b/docs/root/configuration/http/caches_v2/file_system.rst @@ -0,0 +1,18 @@ +.. _config_http_caches_v2_file_system_http_cache: + +File System Http Cache +====================== + +The file system cache caches http responses in a specified file system directory. + +A maximum size or maximum number of entries may be specified; upon exceeding that limit, the cache will remove some of the least recently used entries. + +.. note:: + + This extension is not yet supported on Windows. + +Configuration +------------- + +* This filter should be configured with the type URL ``type.googleapis.com/envoy.extensions.http.cache_v2.file_system_http_cache.v3.FileSystemHttpCacheV2Config``. +* :ref:`v3 API reference ` diff --git a/docs/root/configuration/http/http.rst b/docs/root/configuration/http/http.rst index a118e822bc326..28997cbbcfe0b 100644 --- a/docs/root/configuration/http/http.rst +++ b/docs/root/configuration/http/http.rst @@ -7,5 +7,6 @@ HTTP http_conn_man/http_conn_man http_filters/http_filters caches/caches + caches_v2/caches cluster_specifier/cluster_specifier tcp_bridge/tcp_bridge diff --git a/docs/root/configuration/http/http_filters/_include/http-cache-v2-configuration-internal-listener.yaml b/docs/root/configuration/http/http_filters/_include/http-cache-v2-configuration-internal-listener.yaml new file mode 100644 index 0000000000000..c22b18f32bc9f --- /dev/null +++ b/docs/root/configuration/http/http_filters/_include/http-cache-v2-configuration-internal-listener.yaml @@ -0,0 +1,113 @@ +bootstrap_extensions: +- name: envoy.bootstrap.internal_listener + typed_config: + "@type": type.googleapis.com/envoy.extensions.bootstrap.internal_listener.v3.InternalListener +static_resources: + listeners: + - address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: backend + response_headers_to_add: + - header: + key: x-something + value: something + domains: + - "*" + routes: + - match: + prefix: "/service/1" + route: + cluster: service1 + - match: + prefix: "/service/2" + route: + cluster: service2 + http_filters: + - name: "envoy.filters.http.cache_v2" + typed_config: + "@type": "type.googleapis.com/envoy.extensions.filters.http.cache_v2.v3.CacheV2Config" + override_upstream_cluster: cache_internal_listener_cluster + typed_config: + "@type": "type.googleapis.com/envoy.extensions.http.cache_v2.simple_http_cache.v3.SimpleHttpCacheV2Config" + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + - name: cache_internal_listener + internal_listener: {} + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: cache_internal_listener + route_config: + name: local_route + virtual_hosts: + - name: backend + response_headers_to_add: + - header: + key: x-something + value: something + domains: + - "*" + routes: + - match: + prefix: "/service/1" + route: + cluster: service1 + - match: + prefix: "/service/2" + route: + cluster: service2 + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: service1 + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: service1 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: service1 + port_value: 8000 + - name: service2 + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: service2 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: service2 + port_value: 8000 + - name: cache_internal_listener_cluster + load_assignment: + cluster_name: cache_internal_listener_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + envoy_internal_address: + server_listener_name: cache_internal_listener diff --git a/docs/root/configuration/http/http_filters/_include/http-cache-v2-configuration.yaml b/docs/root/configuration/http/http_filters/_include/http-cache-v2-configuration.yaml new file mode 100644 index 0000000000000..9b95f89bb63b5 --- /dev/null +++ b/docs/root/configuration/http/http_filters/_include/http-cache-v2-configuration.yaml @@ -0,0 +1,63 @@ +static_resources: + listeners: + - address: + socket_address: + address: 0.0.0.0 + port_value: 8000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + codec_type: AUTO + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: backend + domains: + - "*" + routes: + - match: + prefix: "/service/1" + route: + cluster: service1 + - match: + prefix: "/service/2" + route: + cluster: service2 + http_filters: + - name: "envoy.filters.http.cache_v2" + typed_config: + "@type": "type.googleapis.com/envoy.extensions.filters.http.cache_v2.v3.CacheV2Config" + typed_config: + "@type": "type.googleapis.com/envoy.extensions.http.cache_v2.simple_http_cache.v3.SimpleHttpCacheV2Config" + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: service1 + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: service1 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: service1 + port_value: 8000 + - name: service2 + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: service2 + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: service2 + port_value: 8000 diff --git a/docs/root/configuration/http/http_filters/cache_filter.rst b/docs/root/configuration/http/http_filters/cache_filter.rst index 1527e0e1693f1..1edd5a146e7ae 100644 --- a/docs/root/configuration/http/http_filters/cache_filter.rst +++ b/docs/root/configuration/http/http_filters/cache_filter.rst @@ -81,3 +81,6 @@ Example filter configuration with a ``FileSystemHttpCache`` cache implementation :ref:`Persistent on-disk storage backend ` Docs page for File System Http Cache; links to ``FileSystemHttpCacheConfig`` API reference. + + :ref:`Cache filter V2 ` + Version 2 of the cache filter. diff --git a/docs/root/configuration/http/http_filters/cache_v2_filter.rst b/docs/root/configuration/http/http_filters/cache_v2_filter.rst new file mode 100644 index 0000000000000..8d2062d65b388 --- /dev/null +++ b/docs/root/configuration/http/http_filters/cache_v2_filter.rst @@ -0,0 +1,83 @@ +.. _config_http_filters_cache_v2: + +CacheV2 filter +============== + +* This filter should be configured with the type URL ``type.googleapis.com/envoy.extensions.filters.http.cache_v2.v3.CacheV2Config``. +* :ref:`v3 API reference ` +* :ref:`v3 SimpleHTTPCache API reference ` +* This filter doesn't support virtual host-specific configurations. +* When the cache is enabled, cacheable requests are only sent through filters in the + :ref:`upstream_http_filters ` + chain and *not* through any filters in the regular filter chain that are further + upstream than the cache filter, while non-cacheable requests still go through the + listener filter chain. It is therefore recommended for consistency that only the + router filter should be further upstream in the listener filter chain than the + cache filter, and even then only if the router filter does not perform any mutations + such as if ``request_headers_to_add`` is set. + +.. image:: /_static/cache-v2-filter-chain.svg + :width: 80% + :align: center + +* For more complex filter chains where some filters must be upstream of the cache + filter for correct behavior, or if the router filter is configured to perform + mutations via + :ref:`RouteConfiguration ` + the recommended way to configure this so that it works correctly is to configure + an internal listener which duplicates the part of the filter chain that is + upstream of the cache filter, and the ``RouteConfiguration``. + +.. image:: /_static/cache-v2-filter-internal-listener.svg + :width: 80% + :align: center + +The HTTP Cache filter implements most of the complexity of HTTP caching semantics. + +For HTTP Requests: + +* HTTP Cache respects request's ``Cache-Control`` directive. For example, if request comes with ``Cache-Control: no-store`` the request won't be cached, unless + :ref:`ignore_request_cache_control_header ` is true. +* HTTP Cache wont store HTTP HEAD Requests. + +For HTTP Responses: + +* HTTP Cache only caches responses with enough data to calculate freshness lifetime as per `RFC7234 `_. +* HTTP Cache respects ``Cache-Control`` directive from the upstream host. For example, if HTTP response returns status code 200 with ``Cache-Control: max-age=60`` and no ``vary`` header, it will be cached. +* HTTP Cache only caches responses with status codes: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 451, 501. + +HTTP Cache delegates the actual storage of HTTP responses to implementations of the ``HttpCache`` interface. These implementations can +cover all points on the spectrum of persistence, performance, and distribution, from local RAM caches to globally distributed +persistent caches. They can be fully custom caches, or wrappers/adapters around local or remote open-source or proprietary caches. +Currently the only available cache storage implementation is :ref:`SimpleHTTPCache `. + +Example configuration +--------------------- + +Example filter configuration with a ``SimpleHttpCache`` cache implementation: + +.. literalinclude:: _include/http-cache-v2-configuration.yaml + :language: yaml + :lines: 29-34 + :linenos: + :lineno-start: 29 + :caption: :download:`http-cache-v2-configuration.yaml <_include/http-cache-v2-configuration.yaml>` + +The more complicated filter chain configuration required if mutations occur upstream of the cache filter +involves duplicating the full route config into an internal listener (unfortunately this is currently unavoidable): + +.. literalinclude:: _include/http-cache-v2-configuration-internal-listener.yaml + :language: yaml + :lines: 38-113 + :linenos: + :lineno-start: 38 + :caption: :download:`http-cache-v2-configuration-internal-listener.yaml <_include/http-cache-v2-configuration-internal-listener.yaml>` + +.. TODO(ravenblackx): Add sandbox and link it below, similar to what cache_filter does. + +.. TODO(ravenblackx): Update the docs like the recent update to the old cache docs. + +.. seealso:: + + :ref:`Old cache filter ` + The deprecated cache filter. diff --git a/docs/root/configuration/http/http_filters/http_filters.rst b/docs/root/configuration/http/http_filters/http_filters.rst index 29d6bcaf2bbbd..5b2daf552733d 100644 --- a/docs/root/configuration/http/http_filters/http_filters.rst +++ b/docs/root/configuration/http/http_filters/http_filters.rst @@ -15,6 +15,7 @@ HTTP filters basic_auth_filter buffer_filter cache_filter + cache_v2_filter cdn_loop_filter checksum_filter compressor_filter diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index bb92ceb0ce40d..fa12d55240b97 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -147,6 +147,7 @@ EXTENSIONS = { "envoy.filters.http.basic_auth": "//source/extensions/filters/http/basic_auth:config", "envoy.filters.http.buffer": "//source/extensions/filters/http/buffer:config", "envoy.filters.http.cache": "//source/extensions/filters/http/cache:config", + "envoy.filters.http.cache_v2": "//source/extensions/filters/http/cache_v2:config", "envoy.filters.http.cdn_loop": "//source/extensions/filters/http/cdn_loop:config", "envoy.filters.http.compressor": "//source/extensions/filters/http/compressor:config", "envoy.filters.http.cors": "//source/extensions/filters/http/cors:config", @@ -342,8 +343,10 @@ EXTENSIONS = { # # CacheFilter plugins # - "envoy.extensions.http.cache.file_system_http_cache": "//source/extensions/http/cache/file_system_http_cache:config", - "envoy.extensions.http.cache.simple": "//source/extensions/http/cache/simple_http_cache:config", + "envoy.extensions.http.cache.file_system_http_cache": "//source/extensions/http/cache/file_system_http_cache:config", + "envoy.extensions.http.cache.simple": "//source/extensions/http/cache/simple_http_cache:config", + "envoy.extensions.http.cache_v2.file_system_http_cache": "//source/extensions/http/cache_v2/file_system_http_cache:config", + "envoy.extensions.http.cache_v2.simple": "//source/extensions/http/cache_v2/simple_http_cache:config", # # Internal redirect predicates diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index e6b7fc47b7280..d60b3ecf20e90 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -103,6 +103,20 @@ envoy.extensions.http.cache.simple: status: wip type_urls: - envoy.extensions.http.cache.simple_http_cache.v3.SimpleHttpCacheConfig +envoy.extensions.http.cache_v2.file_system_http_cache: + categories: + - envoy.http.cache_v2 + security_posture: unknown + status: wip + type_urls: + - envoy.extensions.http.cache_v2.file_system_http_cache.v3.FileSystemHttpCacheV2Config +envoy.extensions.http.cache_v2.simple: + categories: + - envoy.http.cache_v2 + security_posture: unknown + status: wip + type_urls: + - envoy.extensions.http.cache_v2.simple_http_cache.v3.SimpleHttpCacheV2Config envoy.clusters.aggregate: categories: - envoy.clusters @@ -288,6 +302,13 @@ envoy.filters.http.cache: status: wip type_urls: - envoy.extensions.filters.http.cache.v3.CacheConfig +envoy.filters.http.cache_v2: + categories: + - envoy.filters.http + security_posture: unknown + status: wip + type_urls: + - envoy.extensions.filters.http.cache_v2.v3.CacheV2Config envoy.filters.http.cdn_loop: categories: - envoy.filters.http diff --git a/source/extensions/filters/http/cache_v2/BUILD b/source/extensions/filters/http/cache_v2/BUILD new file mode 100644 index 0000000000000..9752f5b164a23 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/BUILD @@ -0,0 +1,245 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", + "envoy_proto_library", +) + +licenses(["notice"]) # Apache 2 + +## Pluggable HTTP cache filter + +envoy_extension_package() + +envoy_cc_library( + name = "http_source_interface", + hdrs = ["http_source.h"], + deps = [ + ":range_utils_lib", + "//envoy/buffer:buffer_interface", + "//envoy/http:header_map_interface", + "@com_google_absl//absl/functional:any_invocable", + ], +) + +envoy_cc_library( + name = "upstream_request_lib", + srcs = ["upstream_request_impl.cc"], + hdrs = [ + "upstream_request.h", + "upstream_request_impl.h", + ], + deps = [ + ":http_source_interface", + ":range_utils_lib", + ":stats", + "//source/common/buffer:watermark_buffer_lib", + "//source/common/common:cancel_wrapper_lib", + "//source/common/common:logger_lib", + "@com_google_absl//absl/types:variant", + ], +) + +envoy_cc_library( + name = "cache_sessions_lib", + srcs = [ + "cache_sessions.cc", + ], + hdrs = [ + "cache_sessions.h", + ], + deps = [ + ":http_cache_lib", + ":stats", + ":upstream_request_lib", + "//source/common/http:utility_lib", + ], +) + +envoy_cc_library( + name = "cache_sessions_impl_lib", + srcs = [ + "cache_sessions_impl.cc", + ], + hdrs = [ + "cache_sessions_impl.h", + ], + deps = [ + ":cache_sessions_lib", + ":cacheability_utils_lib", + ":upstream_request_lib", + "//source/common/common:cancel_wrapper_lib", + ], +) + +envoy_cc_library( + name = "cache_filter_lib", + srcs = [ + "cache_filter.cc", + ], + hdrs = [ + "cache_filter.h", + ], + deps = [ + ":cache_custom_headers", + ":cache_entry_utils_lib", + ":cache_headers_utils_lib", + ":cache_sessions_impl_lib", + ":cache_sessions_lib", + ":cacheability_utils_lib", + ":http_cache_lib", + ":stats", + ":upstream_request_lib", + "//source/common/buffer:buffer_lib", + "//source/common/common:cancel_wrapper_lib", + "//source/common/common:enum_to_int", + "//source/common/common:logger_lib", + "//source/common/common:macros", + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/common/http:utility_lib", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "@envoy_api//envoy/extensions/filters/http/cache_v2/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "cacheability_utils_lib", + srcs = ["cacheability_utils.cc"], + hdrs = ["cacheability_utils.h"], + deps = [ + ":cache_custom_headers", + ":cache_headers_utils_lib", + "//source/common/common:utility_lib", + "//source/common/http:headers_lib", + ], +) + +envoy_cc_library( + name = "cache_entry_utils_lib", + srcs = ["cache_entry_utils.cc"], + hdrs = ["cache_entry_utils.h"], + deps = [ + ":cache_headers_utils_lib", + "//envoy/common:time_interface", + "//source/common/common:utility_lib", + ], +) + +envoy_cc_library( + name = "cache_policy_lib", + hdrs = ["cache_policy.h"], + deps = [ + ":cache_headers_utils_lib", + ":http_cache_lib", + "//source/common/http:header_map_lib", + ], +) + +envoy_proto_library( + name = "key", + srcs = ["key.proto"], +) + +envoy_cc_library( + name = "cache_progress_receiver_interface", + hdrs = ["cache_progress_receiver.h"], + deps = [ + ":range_utils_lib", + "//envoy/http:header_map_interface", + ], +) + +envoy_cc_library( + name = "http_cache_lib", + srcs = ["http_cache.cc"], + hdrs = ["http_cache.h"], + deps = [ + ":cache_custom_headers", + ":cache_entry_utils_lib", + ":cache_headers_utils_lib", + ":cache_progress_receiver_interface", + ":http_source_interface", + ":key_cc_proto", + ":range_utils_lib", + "//envoy/buffer:buffer_interface", + "//envoy/common:time_interface", + "//envoy/config:typed_config_interface", + "//envoy/http:codes_interface", + "//envoy/http:header_map_interface", + "//source/common/common:assert_lib", + "//source/common/http:header_utility_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf:deterministic_hash_lib", + "@envoy_api//envoy/extensions/filters/http/cache_v2/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "range_utils_lib", + srcs = ["range_utils.cc"], + hdrs = ["range_utils.h"], + deps = [ + ":cache_headers_utils_lib", + ":key_cc_proto", + "//envoy/http:header_map_interface", + "//envoy/protobuf:message_validator_interface", + "//source/common/common:assert_lib", + "//source/common/common:logger_lib", + "//source/common/http:headers_lib", + "@com_google_absl//absl/types:optional", + "@envoy_api//envoy/extensions/filters/http/cache_v2/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "cache_headers_utils_lib", + srcs = ["cache_headers_utils.cc"], + hdrs = ["cache_headers_utils.h"], + deps = [ + ":cache_custom_headers", + ":key_cc_proto", + "//envoy/common:time_interface", + "//envoy/http:header_map_interface", + "//source/common/common:matchers_lib", + "//source/common/http:header_map_lib", + "//source/common/http:header_utility_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf", + "@com_google_absl//absl/container:btree", + "@com_google_absl//absl/types:optional", + "@envoy_api//envoy/extensions/filters/http/cache_v2/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "cache_custom_headers", + srcs = ["cache_custom_headers.cc"], + hdrs = ["cache_custom_headers.h"], + deps = [ + "//source/common/http:headers_lib", + ], +) + +envoy_cc_extension( + name = "stats", + srcs = ["stats.cc"], + hdrs = ["stats.h"], + deps = [ + ":cache_entry_utils_lib", + ], +) + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + ":cache_filter_lib", + ":cache_sessions_lib", + ":stats", + "//source/extensions/filters/http/common:factory_base_lib", + "@envoy_api//envoy/extensions/filters/http/cache_v2/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/http/cache_v2/cache_custom_headers.cc b/source/extensions/filters/http/cache_v2/cache_custom_headers.cc new file mode 100644 index 0000000000000..5c1fed2ae7485 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_custom_headers.cc @@ -0,0 +1,52 @@ +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +using RequestHeaderHandle = Http::CustomInlineHeaderRegistry::Handle< + Http::CustomInlineHeaderRegistry::Type::RequestHeaders>; +using ResponseHeaderHandle = Http::CustomInlineHeaderRegistry::Handle< + Http::CustomInlineHeaderRegistry::Type::ResponseHeaders>; + +static CacheCustomHeaders custom_headers; + +// clang-format off +const RequestHeaderHandle CacheCustomHeaders::authorization() { return custom_headers.authorization_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::pragma() { return custom_headers.pragma_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::requestCacheControl() { return custom_headers.request_cache_control_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::ifMatch() { return custom_headers.if_match_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::ifNoneMatch() { return custom_headers.if_none_match_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::ifModifiedSince() { return custom_headers.if_modified_since_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::ifUnmodifiedSince() { return custom_headers.if_unmodified_since_.handle(); } +const RequestHeaderHandle CacheCustomHeaders::ifRange() { return custom_headers.if_range_.handle(); } + +const ResponseHeaderHandle CacheCustomHeaders::responseCacheControl() { return custom_headers.response_cache_control_.handle(); } +const ResponseHeaderHandle CacheCustomHeaders::lastModified() { return custom_headers.last_modified_.handle(); } +const ResponseHeaderHandle CacheCustomHeaders::age() { return custom_headers.age_.handle(); } +const ResponseHeaderHandle CacheCustomHeaders::etag() { return custom_headers.etag_.handle(); } +const ResponseHeaderHandle CacheCustomHeaders::expires() { return custom_headers.expires_.handle(); } +// clang-format on + +// clang-format off +CacheCustomHeaders::CacheCustomHeaders() + : authorization_(Http::CustomHeaders::get().Authorization), + pragma_(Http::CustomHeaders::get().Pragma), + request_cache_control_(Http::CustomHeaders::get().CacheControl), + if_match_(Http::CustomHeaders::get().IfMatch), + if_none_match_(Http::CustomHeaders::get().IfNoneMatch), + if_modified_since_(Http::CustomHeaders::get().IfModifiedSince), + if_unmodified_since_(Http::CustomHeaders::get().IfUnmodifiedSince), + if_range_(Http::CustomHeaders::get().IfRange), + response_cache_control_(Http::CustomHeaders::get().CacheControl), + last_modified_(Http::CustomHeaders::get().LastModified), + etag_(Http::CustomHeaders::get().Etag), + age_(Http::CustomHeaders::get().Age), + expires_(Http::CustomHeaders::get().Expires) {} +// clang-format on + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_custom_headers.h b/source/extensions/filters/http/cache_v2/cache_custom_headers.h new file mode 100644 index 0000000000000..b072ac96d8f6b --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_custom_headers.h @@ -0,0 +1,57 @@ +#pragma once + +#include "envoy/http/header_map.h" + +#include "source/common/http/headers.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +/** + * CacheCustomHeaders provides access to registered cache-specific headers. + */ +struct CacheCustomHeaders { + CacheCustomHeaders(); + + // clang-format off + const static Http::CustomInlineHeaderRegistry::Handle authorization(); + const static Http::CustomInlineHeaderRegistry::Handle pragma(); + const static Http::CustomInlineHeaderRegistry::Handle requestCacheControl(); + const static Http::CustomInlineHeaderRegistry::Handle ifMatch(); + const static Http::CustomInlineHeaderRegistry::Handle ifNoneMatch(); + const static Http::CustomInlineHeaderRegistry::Handle ifModifiedSince(); + const static Http::CustomInlineHeaderRegistry::Handle ifUnmodifiedSince(); + const static Http::CustomInlineHeaderRegistry::Handle ifRange(); + + const static Http::CustomInlineHeaderRegistry::Handle responseCacheControl(); + const static Http::CustomInlineHeaderRegistry::Handle lastModified(); + const static Http::CustomInlineHeaderRegistry::Handle etag(); + const static Http::CustomInlineHeaderRegistry::Handle age(); + const static Http::CustomInlineHeaderRegistry::Handle expires(); + // clang-format on + + // clang-format off + Http::RegisterCustomInlineHeader authorization_; + Http::RegisterCustomInlineHeader pragma_; + Http::RegisterCustomInlineHeader request_cache_control_; + Http::RegisterCustomInlineHeader if_match_; + Http::RegisterCustomInlineHeader if_none_match_; + Http::RegisterCustomInlineHeader if_modified_since_; + Http::RegisterCustomInlineHeader if_unmodified_since_; + Http::RegisterCustomInlineHeader if_range_; + + Http::RegisterCustomInlineHeader response_cache_control_; + Http::RegisterCustomInlineHeader last_modified_; + Http::RegisterCustomInlineHeader etag_; + Http::RegisterCustomInlineHeader age_; + Http::RegisterCustomInlineHeader expires_; + // clang-format on + +}; // Request headers inline handles + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_entry_utils.cc b/source/extensions/filters/http/cache_v2/cache_entry_utils.cc new file mode 100644 index 0000000000000..2a487a9e4107b --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_entry_utils.cc @@ -0,0 +1,96 @@ +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" + +#include "absl/strings/str_format.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +absl::string_view cacheEntryStatusString(CacheEntryStatus s) { + switch (s) { + case CacheEntryStatus::Hit: + return "Hit"; + case CacheEntryStatus::Miss: + return "Miss"; + case CacheEntryStatus::Follower: + return "Follower"; + case CacheEntryStatus::Uncacheable: + return "Uncacheable"; + case CacheEntryStatus::Validated: + return "Validated"; + case CacheEntryStatus::ValidatedFree: + return "ValidatedFree"; + case CacheEntryStatus::FailedValidation: + return "FailedValidation"; + case CacheEntryStatus::FoundNotModified: + return "FoundNotModified"; + case CacheEntryStatus::LookupError: + return "LookupError"; + case CacheEntryStatus::UpstreamReset: + return "UpstreamReset"; + } + IS_ENVOY_BUG(absl::StrCat("Unexpected CacheEntryStatus: ", s)); + return "UnexpectedCacheEntryStatus"; +} + +std::ostream& operator<<(std::ostream& os, CacheEntryStatus status) { + return os << cacheEntryStatusString(status); +} + +namespace { +const absl::flat_hash_set headersNotToUpdate() { + CONSTRUCT_ON_FIRST_USE( + absl::flat_hash_set, + // Content range should not be changed upon validation + Http::Headers::get().ContentRange, + + // Headers that describe the body content should never be updated. + Http::Headers::get().ContentLength, + + // It does not make sense for this level of the code to be updating the ETag, when + // presumably the cached_response_headers reflect this specific ETag. + Http::CustomHeaders::get().Etag, + + // We don't update the cached response on a Vary; we just delete it + // entirely. So don't bother copying over the Vary header. + Http::CustomHeaders::get().Vary); +} +} // namespace + +void applyHeaderUpdate(const Http::ResponseHeaderMap& new_headers, + Http::ResponseHeaderMap& headers_to_update) { + // Assumptions: + // 1. The internet is fast, i.e. we get the result as soon as the server sends it. + // Race conditions would not be possible because we are always processing up-to-date data. + // 2. No key collision for etag. Therefore, if etag matches it's the same resource. + // 3. Backend is correct. etag is being used as a unique identifier to the resource + + // use other header fields provided in the new response to replace all instances + // of the corresponding header fields in the stored response + + // `updatedHeaderFields` makes sure each field is only removed when we update the header + // field for the first time to handle the case where incoming headers have repeated values + absl::flat_hash_set updatedHeaderFields; + new_headers.iterate( + [&headers_to_update, &updatedHeaderFields]( + const Http::HeaderEntry& incoming_response_header) -> Http::HeaderMap::Iterate { + Http::LowerCaseString lower_case_key{incoming_response_header.key().getStringView()}; + absl::string_view incoming_value{incoming_response_header.value().getStringView()}; + if (headersNotToUpdate().contains(lower_case_key)) { + return Http::HeaderMap::Iterate::Continue; + } + if (!updatedHeaderFields.contains(lower_case_key)) { + headers_to_update.setCopy(lower_case_key, incoming_value); + updatedHeaderFields.insert(lower_case_key); + } else { + headers_to_update.addCopy(lower_case_key, incoming_value); + } + return Http::HeaderMap::Iterate::Continue; + }); +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_entry_utils.h b/source/extensions/filters/http/cache_v2/cache_entry_utils.h new file mode 100644 index 0000000000000..2e63cca6193e2 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_entry_utils.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +#include "envoy/common/time.h" + +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// The metadata associated with a cached response. +// TODO(yosrym93): This could be changed to a proto if a need arises. +// If a cache was created with the current interface, then it was changed to a +// proto, all the cache entries will need to be invalidated. +struct ResponseMetadata { + // The time at which a response was was most recently inserted, updated, or + // validated in this cache. This represents "response_time" in the age header + // calculations at: https://httpwg.org/specs/rfc7234.html#age.calculations + Envoy::SystemTime response_time_; +}; + +// Whether a given cache entry is good for the current request. +enum class CacheEntryStatus { + // This entry is fresh, and an appropriate response to the request. + Hit, + // The request was cacheable and was not already in the cache. This also means + // the cache was populated by this request. + Miss, + // The entry was being inserted when this request was made - it's like a + // hit, but streamed from the same request as the original "Miss", so still + // potentially subject to upstream reset because the cache entry isn't fully + // populated yet. + Follower, + // The request was not cacheable. All matching requests will go to the + // upstream. + Uncacheable, + // This entry required validation, and validated successfully. + Validated, + // This entry required validation while another entry was already validating, + // so it validated successfully without its own lookup. + ValidatedFree, + // This entry required validation, and did not validate. + FailedValidation, + // This entry is fresh, and an appropriate basis for a 304 Not Modified + // response. + FoundNotModified, + // The cache lookup failed, e.g. because the cache was unreachable or an RPC + // timed out. Mostly behaves the same as Uncacheable but may retry each time. + LookupError, + // The cache attempted to read from upstream for insert, but upstream reset. + UpstreamReset, +}; + +absl::string_view cacheEntryStatusString(CacheEntryStatus s); +std::ostream& operator<<(std::ostream& os, CacheEntryStatus status); + +// For an updateHeaders operation, new headers must be merged into existing headers +// for the cache entry. This helper function performs that merge correctly, i.e. +// - if a header appears in new_headers, prior values for that header are erased +// from headers_to_update. +// - if a header appears more than once in new_headers, all new values are added +// to headers_to_update. +// - headers that are not supposed to be updated during updateHeaders operations +// (etag, content-length, content-range, vary) are ignored. +void applyHeaderUpdate(const Http::ResponseHeaderMap& new_headers, + Http::ResponseHeaderMap& headers_to_update); + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_filter.cc b/source/extensions/filters/http/cache_v2/cache_filter.cc new file mode 100644 index 0000000000000..80ec3fb787534 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_filter.cc @@ -0,0 +1,487 @@ +#include "source/extensions/filters/http/cache_v2/cache_filter.h" + +#include "envoy/http/header_map.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/common/enum_to_int.h" +#include "source/common/http/headers.h" +#include "source/common/http/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/filters/http/cache_v2/cacheability_utils.h" +#include "source/extensions/filters/http/cache_v2/upstream_request_impl.h" + +#include "absl/memory/memory.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +using CancelWrapper::cancelWrapped; + +namespace { +// This value is only used if there is no encoderBufferLimit on the stream; +// without *some* constraint here, a very large chunk can be requested and +// attempt to load into a memory buffer. +// +// This default is quite large to minimize the chance of being a surprise +// behavioral change when a constraint is added. +// +// And everyone knows 64MB should be enough for anyone. +static constexpr size_t MaxBytesToFetchFromCachePerRead = 64 * 1024 * 1024; +} // namespace + +namespace CacheResponseCodeDetails { +static constexpr absl::string_view ResponseFromCacheFilter = "cache.response_from_cache_filter"; +static constexpr absl::string_view CacheFilterInsert = "cache.insert_via_upstream"; +static constexpr absl::string_view CacheFilterAbortedDuringLookup = "cache.aborted_lookup"; +static constexpr absl::string_view CacheFilterAbortedDuringHeaders = "cache.aborted_headers"; +static constexpr absl::string_view CacheFilterAbortedDuringBody = "cache.aborted_body"; +static constexpr absl::string_view CacheFilterAbortedDuringTrailers = "cache.aborted_trailers"; +} // namespace CacheResponseCodeDetails + +CacheFilterConfig::CacheFilterConfig( + const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& config, + std::shared_ptr cache_sessions, + Server::Configuration::CommonFactoryContext& context) + : vary_allow_list_(config.allowed_vary_headers(), context), time_source_(context.timeSource()), + ignore_request_cache_control_header_(config.ignore_request_cache_control_header()), + cluster_manager_(context.clusterManager()), cache_sessions_(std::move(cache_sessions)), + override_upstream_cluster_(config.override_upstream_cluster()) {} + +bool CacheFilterConfig::isCacheableResponse(const Http::ResponseHeaderMap& headers) const { + return CacheabilityUtils::isCacheableResponse(headers, vary_allow_list_); +} + +CacheFilter::CacheFilter(std::shared_ptr config) : config_(config) {} + +void CacheFilter::setDecoderFilterCallbacks(Http::StreamDecoderFilterCallbacks& callbacks) { + callbacks.addDownstreamWatermarkCallbacks(*this); + PassThroughFilter::setDecoderFilterCallbacks(callbacks); +} + +void CacheFilter::onDestroy() { + is_destroyed_ = true; + if (cancel_in_flight_callback_) { + cancel_in_flight_callback_(); + } + lookup_result_.reset(); +} + +absl::optional CacheFilter::clusterName() { + Router::RouteConstSharedPtr route = decoder_callbacks_->route(); + const Router::RouteEntry* route_entry = (route == nullptr) ? nullptr : route->routeEntry(); + if (route_entry == nullptr) { + return absl::nullopt; + } + return route_entry->clusterName(); +} + +OptRef CacheFilter::asyncClient(absl::string_view cluster_name) { + Upstream::ThreadLocalCluster* thread_local_cluster = + config_->clusterManager().getThreadLocalCluster(cluster_name); + if (thread_local_cluster == nullptr) { + return absl::nullopt; + } + return thread_local_cluster->httpAsyncClient(); +} + +void CacheFilter::sendNoRouteResponse() { + decoder_callbacks_->sendLocalReply(Http::Code::NotFound, "", nullptr, absl::nullopt, + "cache_no_route"); +} + +void CacheFilter::sendNoClusterResponse(absl::string_view cluster_name) { + ENVOY_STREAM_LOG(debug, "upstream cluster '{}' was not available to cache", *decoder_callbacks_, + cluster_name); + decoder_callbacks_->sendLocalReply(Http::Code::ServiceUnavailable, "", nullptr, absl::nullopt, + "cache_no_cluster"); +} + +Http::FilterHeadersStatus CacheFilter::decodeHeaders(Http::RequestHeaderMap& headers, + bool end_stream) { + ASSERT(decoder_callbacks_); + if (!config_->hasCache()) { + return Http::FilterHeadersStatus::Continue; + } + if (!end_stream) { + ENVOY_STREAM_LOG(debug, + "CacheFilter::decodeHeaders ignoring request because it has body and/or " + "trailers: headers={}", + *decoder_callbacks_, headers); + stats().incForStatus(CacheEntryStatus::Uncacheable); + return Http::FilterHeadersStatus::Continue; + } + absl::Status can_serve = CacheabilityUtils::canServeRequestFromCache(headers); + if (!can_serve.ok()) { + ENVOY_STREAM_LOG(debug, + "CacheFilter::decodeHeaders ignoring uncacheable request: {}\nheaders={}", + *decoder_callbacks_, can_serve, headers); + stats().incForStatus(CacheEntryStatus::Uncacheable); + return Http::FilterHeadersStatus::Continue; + } + ENVOY_STREAM_LOG(debug, "CacheFilter::decodeHeaders: {}", *decoder_callbacks_, headers); + + absl::optional original_cluster_name = clusterName(); + absl::string_view cluster_name; + if (config_->overrideUpstreamCluster().empty()) { + if (!original_cluster_name) { + sendNoRouteResponse(); + return Http::FilterHeadersStatus::StopIteration; + } + cluster_name = *original_cluster_name; + } else { + cluster_name = config_->overrideUpstreamCluster(); + if (!original_cluster_name) { + // It's possible the destination cluster will only be determined further upstream in + // the cache filter's side-channel, in which case we can't use it in the key; + // in this case use "unknown" instead. + original_cluster_name = "unknown"; + } + } + OptRef async_client = asyncClient(cluster_name); + if (!async_client) { + sendNoClusterResponse(cluster_name); + return Http::FilterHeadersStatus::StopIteration; + } + auto upstream_request_factory = std::make_unique( + decoder_callbacks_->dispatcher(), *async_client, config_->upstreamOptions()); + auto lookup_request = std::make_unique( + headers, std::move(upstream_request_factory), *original_cluster_name, + decoder_callbacks_->dispatcher(), config_->timeSource().systemTime(), config_, config_, + config_->ignoreRequestCacheControlHeader()); + is_head_request_ = headers.getMethodValue() == Http::Headers::get().MethodValues.Head; + ENVOY_STREAM_LOG(debug, "CacheFilter::decodeHeaders starting lookup", *decoder_callbacks_); + config_->cacheSessions().lookup( + std::move(lookup_request), + cancelWrapped( + [this](ActiveLookupResultPtr lookup_result) { onLookupResult(std::move(lookup_result)); }, + &cancel_in_flight_callback_)); + + // Stop the decoding stream. + return Http::FilterHeadersStatus::StopIteration; +} + +static absl::string_view responseCodeDetailsFromStatus(CacheEntryStatus status) { + switch (status) { + case CacheEntryStatus::Miss: + case CacheEntryStatus::FailedValidation: + return CacheResponseCodeDetails::CacheFilterInsert; + case CacheEntryStatus::Hit: + case CacheEntryStatus::FoundNotModified: + case CacheEntryStatus::Follower: + case CacheEntryStatus::Validated: + case CacheEntryStatus::ValidatedFree: + case CacheEntryStatus::UpstreamReset: + return CacheResponseCodeDetails::ResponseFromCacheFilter; + case CacheEntryStatus::Uncacheable: + case CacheEntryStatus::LookupError: + break; + } + return StreamInfo::ResponseCodeDetails::get().ViaUpstream; +} + +void CacheFilter::onLookupResult(ActiveLookupResultPtr lookup_result) { + ASSERT(lookup_result != nullptr, "lookup result should always be non-null"); + lookup_result_ = std::move(lookup_result); + if (!lookup_result_->http_source_) { + // Lookup failed, typically implying upstream request was reset. + decoder_callbacks_->streamInfo().setResponseCodeDetails( + CacheResponseCodeDetails::CacheFilterAbortedDuringLookup); + decoder_callbacks_->resetStream(); + return; + } + + stats().incForStatus(lookup_result_->status_); + if (lookup_result_->status_ != CacheEntryStatus::Uncacheable) { + decoder_callbacks_->streamInfo().setResponseFlag( + StreamInfo::CoreResponseFlag::ResponseFromCacheFilter); + } + + ENVOY_STREAM_LOG(debug, "CacheFilter calling getHeaders", *decoder_callbacks_); + lookup_result_->http_source_->getHeaders(cancelWrapped( + [this](Http::ResponseHeaderMapPtr response_headers, EndStream end_stream_enum) { + onHeaders(std::move(response_headers), end_stream_enum); + }, + &cancel_in_flight_callback_)); +} + +Http::FilterHeadersStatus CacheFilter::encodeHeaders(Http::ResponseHeaderMap& headers, bool) { + if (lookup_result_) { + // This call was invoked during decoding by decoder_callbacks_->encodeHeaders with data + // either read from the upstream via the cache filter, or from the cache. + return Http::FilterHeadersStatus::Continue; + } + if (!cancel_in_flight_callback_) { + // If there was no lookup result and there's no request in flight, this implies + // no request was sent, so we must be in a pass-through configuration (either no + // cache or the request had a body). + return Http::FilterHeadersStatus::Continue; + } + + // Filter chain iteration is paused while a lookup is outstanding, but the filter chain manager + // can still generate a local reply. One case where this can happen is when a downstream idle + // timeout fires, which may mean that the HttpCache isn't correctly setting deadlines on its + // asynchronous operations or is otherwise getting stuck. + ENVOY_BUG(Http::Utility::getResponseStatus(headers) != + Envoy::enumToInt(Http::Code::RequestTimeout), + "Request timed out while cache lookup was outstanding."); + // Cancel the lookup since it's now not useful. + ASSERT(cancel_in_flight_callback_); + cancel_in_flight_callback_(); + return Http::FilterHeadersStatus::Continue; +} + +void CacheFilter::getBody() { + ASSERT(lookup_result_, "CacheFilter is trying to call getBody with no LookupResult"); + get_body_loop_ = GetBodyLoop::Again; + while (get_body_loop_ == GetBodyLoop::Again) { + ASSERT(!remaining_ranges_.empty(), "No reason to call getBody when there's no body to get."); + + // We don't want to request more than a buffer-size at a time from the cache. + uint64_t fetch_size_limit = encoder_callbacks_->encoderBufferLimit(); + // If there is no buffer size limit, we still want *some* constraint. + if (fetch_size_limit == 0) { + fetch_size_limit = MaxBytesToFetchFromCachePerRead; + } + AdjustedByteRange fetch_range = {remaining_ranges_[0].begin(), + (remaining_ranges_[0].length() > fetch_size_limit) + ? (remaining_ranges_[0].begin() + fetch_size_limit) + : remaining_ranges_[0].end()}; + + ENVOY_STREAM_LOG(debug, "CacheFilter calling getBody", *decoder_callbacks_); + get_body_loop_ = GetBodyLoop::InCallback; + lookup_result_->http_source_->getBody( + fetch_range, cancelWrapped( + [this, &dispatcher = decoder_callbacks_->dispatcher()]( + Buffer::InstancePtr&& body, EndStream end_stream_enum) { + if (onBody(std::move(body), end_stream_enum)) { + if (get_body_loop_ == GetBodyLoop::InCallback) { + // If the callback was called inline, loop it. + get_body_loop_ = GetBodyLoop::Again; + } else { + // If the callback was posted we're not in the loop + // any more, so getBody to enter the loop. + getBody(); + } + } + }, + &cancel_in_flight_callback_)); + } + get_body_loop_ = GetBodyLoop::Idle; +} + +void CacheFilter::getTrailers() { + ASSERT(lookup_result_, "CacheFilter is trying to call getTrailers with no LookupResult"); + + lookup_result_->http_source_->getTrailers(cancelWrapped( + [this, &dispatcher = decoder_callbacks_->dispatcher()](Http::ResponseTrailerMapPtr&& trailers, + EndStream end_stream_enum) { + ASSERT( + dispatcher.isThreadSafe(), + "caches must ensure the callback is called from the original thread, either by posting " + "to dispatcher or by calling directly"); + onTrailers(std::move(trailers), end_stream_enum); + }, + &cancel_in_flight_callback_)); +} + +static AdjustedByteRange rangeFromHeaders(Http::ResponseHeaderMap& response_headers) { + if (Http::Utility::getResponseStatus(response_headers) != + static_cast(Envoy::Http::Code::PartialContent)) { + // Don't use content-length; we can just request *all the body* from + // the source and it will tell us when it gets to the end. + return {0, std::numeric_limits::max()}; + } + Http::HeaderMap::GetResult content_range_result = + response_headers.get(Envoy::Http::Headers::get().ContentRange); + if (content_range_result.empty()) { + return {0, std::numeric_limits::max()}; + } + absl::string_view content_range = content_range_result[0]->value().getStringView(); + if (!absl::ConsumePrefix(&content_range, "bytes ")) { + return {0, std::numeric_limits::max()}; + } + if (absl::ConsumePrefix(&content_range, "*/")) { + uint64_t len; + if (absl::SimpleAtoi(content_range, &len)) { + return {0, len}; + } + return {0, std::numeric_limits::max()}; + } + std::pair range_of = absl::StrSplit(content_range, '/'); + std::pair range = absl::StrSplit(range_of.first, '-'); + uint64_t begin, end; + if (!absl::SimpleAtoi(range.first, &begin)) { + begin = 0; + } + if (!absl::SimpleAtoi(range.second, &end)) { + end = std::numeric_limits::max(); + } else { + end++; + } + return {begin, end}; +} + +void CacheFilter::onHeaders(Http::ResponseHeaderMapPtr response_headers, + EndStream end_stream_enum) { + ASSERT(lookup_result_, "onHeaders should not be called with no LookupResult"); + + if (end_stream_enum == EndStream::Reset) { + decoder_callbacks_->streamInfo().setResponseCodeDetails( + CacheResponseCodeDetails::CacheFilterAbortedDuringHeaders); + decoder_callbacks_->resetStream(); + return; + } + ASSERT(response_headers != nullptr); + + if (lookup_result_->status_ == CacheEntryStatus::Miss || + lookup_result_->status_ == CacheEntryStatus::Validated || + lookup_result_->status_ == CacheEntryStatus::ValidatedFree) { + // CacheSessions adds an age header indiscriminately because once it has + // handed off it doesn't remember which request is associated with the insert. + // So here we remove that header for the non-cache response and the validated + // response. + response_headers->remove(Envoy::Http::CustomHeaders::get().Age); + } + + static const std::string partial_content = std::to_string(enumToInt(Http::Code::PartialContent)); + if (response_headers->getStatusValue() == partial_content) { + is_partial_response_ = true; + } + + bool end_stream = ((end_stream_enum == EndStream::End) || is_head_request_); + + if (!end_stream) { + remaining_ranges_ = {rangeFromHeaders(*response_headers)}; + ENVOY_STREAM_LOG(debug, "CacheFilter requesting range {}-{} {}", *decoder_callbacks_, + remaining_ranges_[0].begin(), remaining_ranges_[0].end(), *response_headers); + } + + decoder_callbacks_->encodeHeaders(std::move(response_headers), end_stream, + responseCodeDetailsFromStatus(lookup_result_->status_)); + // onDestroy can potentially be called during encodeHeaders. + if (is_destroyed_) { + return; + } + if (end_stream) { + return; + } + return getBody(); +} + +bool CacheFilter::onBody(Buffer::InstancePtr&& body, EndStream end_stream_enum) { + ASSERT(!remaining_ranges_.empty(), + "CacheFilter doesn't call getBody unless there's more body to get, so this is a " + "bogus callback."); + if (end_stream_enum == EndStream::Reset) { + decoder_callbacks_->streamInfo().setResponseCodeDetails( + CacheResponseCodeDetails::CacheFilterAbortedDuringBody); + decoder_callbacks_->resetStream(); + return false; + } + bool end_stream = end_stream_enum == EndStream::End; + + if (body == nullptr) { + // if we called getBody and got a nullptr that implies there was less body + // than expected, or we didn't have complete expectations. + // It should not be treated as a bug here to have incorrect expectations, + // as an untrusted upstream could send mismatched content-length and + // body-stream. + // If there is no body but there are trailers, this is how we know to + // move on to trailers. + if (end_stream) { + Buffer::OwnedImpl empty_buffer; + decoder_callbacks_->encodeData(empty_buffer, true); + finalizeEncodingCachedResponse(); + return false; + } else { + getTrailers(); + return false; + } + } + + const uint64_t bytes_from_cache = body->length(); + if (bytes_from_cache < remaining_ranges_[0].length()) { + remaining_ranges_[0].trimFront(bytes_from_cache); + } else if (bytes_from_cache == remaining_ranges_[0].length()) { + remaining_ranges_.erase(remaining_ranges_.begin()); + } else { + decoder_callbacks_->resetStream(); + IS_ENVOY_BUG("Received oversized body from http source."); + return false; + } + + // For a range request the upstream may not have thought it was end_stream + // but it still could be for the downstream. + // This also covers the case where a range request wanted the last byte and + // trailers are present; in this case we don't send trailers. + // (It is unclear from the spec whether we should, but pragmatically we + // may not have any indication of whether trailers are present or not, and + // range requests in general are for filling in missing chunks so including + // trailers with every chunk would be wasteful.) + if (is_partial_response_ && remaining_ranges_.empty()) { + end_stream = true; + } + + decoder_callbacks_->encodeData(*body, end_stream); + // Filter can potentially be destroyed during encodeData (e.g. if + // encodeData provokes a reset) + if (is_destroyed_) { + return false; + } + + if (end_stream) { + finalizeEncodingCachedResponse(); + return false; + } else if (!remaining_ranges_.empty()) { + if (downstream_watermarked_) { + get_body_on_unblocked_ = true; + return false; + } else { + return true; + } + } else { + getTrailers(); + return false; + } +} + +void CacheFilter::onAboveWriteBufferHighWatermark() { downstream_watermarked_++; } + +void CacheFilter::onBelowWriteBufferLowWatermark() { + if (downstream_watermarked_ == 0) { + IS_ENVOY_BUG("low watermark not preceded by high watermark should not happen"); + } else { + downstream_watermarked_--; + } + if (downstream_watermarked_ == 0 && get_body_on_unblocked_) { + get_body_on_unblocked_ = false; + getBody(); + } +} + +void CacheFilter::onTrailers(Http::ResponseTrailerMapPtr&& trailers, EndStream end_stream_enum) { + ASSERT(!is_destroyed_, "callback should be cancelled when filter is destroyed"); + if (end_stream_enum == EndStream::Reset) { + decoder_callbacks_->streamInfo().setResponseCodeDetails( + CacheResponseCodeDetails::CacheFilterAbortedDuringTrailers); + decoder_callbacks_->resetStream(); + return; + } + decoder_callbacks_->encodeTrailers(std::move(trailers)); + // Filter can potentially be destroyed during encodeTrailers. + if (is_destroyed_) { + return; + } + finalizeEncodingCachedResponse(); +} + +void CacheFilter::finalizeEncodingCachedResponse() {} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_filter.h b/source/extensions/filters/http/cache_v2/cache_filter.h new file mode 100644 index 0000000000000..5570ce0fc76b5 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_filter.h @@ -0,0 +1,139 @@ +#pragma once + +#include +#include + +#include "envoy/extensions/filters/http/cache_v2/v3/cache.pb.h" + +#include "source/common/common/cancel_wrapper.h" +#include "source/common/common/logger.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/filters/http/cache_v2/stats.h" +#include "source/extensions/filters/http/common/pass_through_filter.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// CacheFilterConfig contains everything which is shared by all CacheFilter +// objects created from a given CacheV2Config. +class CacheFilterConfig : public CacheableResponseChecker, public CacheFilterStatsProvider { +public: + CacheFilterConfig(const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& config, + std::shared_ptr cache_sessions, + Server::Configuration::CommonFactoryContext& context); + + // Implements CacheableResponseChecker::isCacheableResponse. + bool isCacheableResponse(const Http::ResponseHeaderMap& headers) const override; + // The allow list rules that decide if a header can be varied upon. + const VaryAllowList& varyAllowList() const { return vary_allow_list_; } + TimeSource& timeSource() const { return time_source_; } + const Http::AsyncClient::StreamOptions& upstreamOptions() const { return upstream_options_; } + Upstream::ClusterManager& clusterManager() const { return cluster_manager_; } + const std::string& overrideUpstreamCluster() const { return override_upstream_cluster_; } + bool ignoreRequestCacheControlHeader() const { return ignore_request_cache_control_header_; } + CacheSessions& cacheSessions() const { return *cache_sessions_; } + bool hasCache() const { return cache_sessions_ != nullptr; } + CacheFilterStats& stats() const override { return cache_sessions_->stats(); } + +private: + const VaryAllowList vary_allow_list_; + TimeSource& time_source_; + const bool ignore_request_cache_control_header_; + Upstream::ClusterManager& cluster_manager_; + Http::AsyncClient::StreamOptions upstream_options_; + std::shared_ptr cache_sessions_; + CacheFilterStatsPtr stats_; + std::string override_upstream_cluster_; +}; + +/** + * A filter that caches responses and attempts to satisfy requests from cache. + */ +class CacheFilter : public Http::PassThroughFilter, + public Http::DownstreamWatermarkCallbacks, + public Logger::Loggable { +public: + CacheFilter(std::shared_ptr config); + // Http::StreamFilterBase + void onDestroy() override; + // Http::StreamDecoderFilter + void setDecoderFilterCallbacks(Http::StreamDecoderFilterCallbacks& callbacks) override; + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, + bool end_stream) override; + // Http::StreamEncoderFilter + Http::FilterHeadersStatus encodeHeaders(Http::ResponseHeaderMap& headers, + bool end_stream) override; + + // Http::DownstreamWatermarkCallbacks + void onAboveWriteBufferHighWatermark() override; + void onBelowWriteBufferLowWatermark() override; + +private: + using CancelFunction = CancelWrapper::CancelFunction; + // Gets the cluster name for the current route, if there is one. + absl::optional clusterName(); + // Gets an AsyncClient for the given cluster, or nullopt if there is no upstream. + OptRef asyncClient(absl::string_view cluster_name); + + // In the event that there is no matching route when attempting to fetch asyncClient, + // send a 404 local response. + void sendNoRouteResponse(); + + // In the event that there is no available cluster when attempting to fetch asyncClient, + // send a 503 local response. + void sendNoClusterResponse(absl::string_view cluster_name); + + // Utility functions; make any necessary checks and call the corresponding lookup_ functions + void getHeaders(Http::RequestHeaderMap& request_headers); + void getBody(); + void getTrailers(); + + void onLookupResult(ActiveLookupResultPtr lookup_result); + void onHeaders(Http::ResponseHeaderMapPtr headers, EndStream end_stream); + // Returns true if getBody should be called again. + bool onBody(Buffer::InstancePtr&& body, EndStream end_stream); + void onTrailers(Http::ResponseTrailerMapPtr&& trailers, EndStream end_stream); + CacheFilterStats& stats() const { return config_->stats(); } + + void finalizeEncodingCachedResponse(); + + std::shared_ptr cache_; + ActiveLookupResultPtr lookup_result_; + bool is_partial_response_ = false; + + // Tracks what body bytes still need to be read from the cache. This is + // currently only one Range, but will expand when full range support is added. Initialized by + // onHeaders for Range Responses, otherwise initialized by encodeCachedResponse. + std::vector remaining_ranges_; + + const std::shared_ptr config_; + + // True if a request allows cache inserts according to: + // https://httpwg.org/specs/rfc7234.html#response.cacheability + bool request_allows_inserts_ = false; + + bool is_destroyed_ = false; + + bool is_head_request_ = false; + // If this is populated it should be called from onDestroy. + CancelFunction cancel_in_flight_callback_; + + int downstream_watermarked_ = 0; + // To avoid a potential recursion stack-overflow, the onBody function + // does not call getBody again directly but instead returns true if + // we *should* call getBody again, allowing it to be a loop rather + // than recursion. + enum class GetBodyLoop { InCallback, Again, Idle } get_body_loop_; + bool get_body_on_unblocked_ = false; +}; + +using CacheFilterSharedPtr = std::shared_ptr; +using CacheFilterWeakPtr = std::weak_ptr; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_headers_utils.cc b/source/extensions/filters/http/cache_v2/cache_headers_utils.cc new file mode 100644 index 0000000000000..62912f6e2b475 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_headers_utils.cc @@ -0,0 +1,468 @@ +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +#include +#include +#include +#include + +#include "envoy/http/header_map.h" + +#include "source/common/common/enum_to_int.h" +#include "source/common/http/header_map_impl.h" +#include "source/common/http/header_utility.h" +#include "source/common/http/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" + +#include "absl/algorithm/container.h" +#include "absl/container/btree_set.h" +#include "absl/strings/ascii.h" +#include "absl/strings/numbers.h" +#include "absl/strings/str_split.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// Utility functions used in RequestCacheControl & ResponseCacheControl. +namespace { +// A directive with an invalid duration is ignored, the RFC does not specify a behavior: +// https://httpwg.org/specs/rfc7234.html#delta-seconds +OptionalDuration parseDuration(absl::string_view s) { + OptionalDuration duration; + // Strip quotation marks if any. + if (s.size() > 1 && s.front() == '"' && s.back() == '"') { + s = s.substr(1, s.size() - 2); + } + long num; + if (absl::SimpleAtoi(s, &num) && num >= 0) { + // s is a valid string of digits representing a positive number. + duration = Seconds(num); + } + return duration; +} + +inline std::pair +separateDirectiveAndArgument(absl::string_view full_directive) { + return absl::StrSplit(absl::StripAsciiWhitespace(full_directive), absl::MaxSplits('=', 1)); +} +} // namespace + +// The grammar for This Cache-Control header value should be: +// Cache-Control = 1#cache-directive +// cache-directive = token [ "=" ( token / quoted-string ) ] +// token = 1*tchar +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" +// / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE +// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text +// obs-text = %x80-FF +// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) +// VCHAR = %x21-7E ; visible (printing) characters + +// Multiple directives are comma separated according to: +// https://httpwg.org/specs/rfc7234.html#collected.abnf + +RequestCacheControl::RequestCacheControl(absl::string_view cache_control_header) { + const std::vector directives = absl::StrSplit(cache_control_header, ','); + + for (auto full_directive : directives) { + absl::string_view directive, argument; + std::tie(directive, argument) = separateDirectiveAndArgument(full_directive); + + if (directive == "no-cache") { + must_validate_ = true; + } else if (directive == "no-store") { + no_store_ = true; + } else if (directive == "no-transform") { + no_transform_ = true; + } else if (directive == "only-if-cached") { + only_if_cached_ = true; + } else if (directive == "max-age") { + max_age_ = parseDuration(argument); + } else if (directive == "min-fresh") { + min_fresh_ = parseDuration(argument); + } else if (directive == "max-stale") { + max_stale_ = argument.empty() ? SystemTime::duration::max() : parseDuration(argument); + } + } +} + +ResponseCacheControl::ResponseCacheControl(absl::string_view cache_control_header) { + const std::vector directives = absl::StrSplit(cache_control_header, ','); + + for (auto full_directive : directives) { + absl::string_view directive, argument; + std::tie(directive, argument) = separateDirectiveAndArgument(full_directive); + + if (directive == "no-cache") { + // If no-cache directive has arguments they are ignored - not handled. + must_validate_ = true; + } else if (directive == "must-revalidate" || directive == "proxy-revalidate") { + no_stale_ = true; + } else if (directive == "no-store" || directive == "private") { + // If private directive has arguments they are ignored - not handled. + no_store_ = true; + } else if (directive == "no-transform") { + no_transform_ = true; + } else if (directive == "public") { + is_public_ = true; + } else if (directive == "s-maxage") { + max_age_ = parseDuration(argument); + } else if (!max_age_.has_value() && directive == "max-age") { + max_age_ = parseDuration(argument); + } + } +} + +bool operator==(const RequestCacheControl& lhs, const RequestCacheControl& rhs) { + return (lhs.must_validate_ == rhs.must_validate_) && (lhs.no_store_ == rhs.no_store_) && + (lhs.no_transform_ == rhs.no_transform_) && (lhs.only_if_cached_ == rhs.only_if_cached_) && + (lhs.max_age_ == rhs.max_age_) && (lhs.min_fresh_ == rhs.min_fresh_) && + (lhs.max_stale_ == rhs.max_stale_); +} + +bool operator==(const ResponseCacheControl& lhs, const ResponseCacheControl& rhs) { + return (lhs.must_validate_ == rhs.must_validate_) && (lhs.no_store_ == rhs.no_store_) && + (lhs.no_transform_ == rhs.no_transform_) && (lhs.no_stale_ == rhs.no_stale_) && + (lhs.is_public_ == rhs.is_public_) && (lhs.max_age_ == rhs.max_age_); +} + +std::ostream& operator<<(std::ostream& os, const RequestCacheControl& request_cache_control) { + std::vector fields; + + if (request_cache_control.must_validate_) { + fields.push_back("must_validate"); + } + if (request_cache_control.no_store_) { + fields.push_back("no_store"); + } + if (request_cache_control.no_transform_) { + fields.push_back("no_transform"); + } + if (request_cache_control.only_if_cached_) { + fields.push_back("only_if_cached"); + } + if (request_cache_control.max_age_.has_value()) { + fields.push_back( + absl::StrCat("max-age=", std::to_string(request_cache_control.max_age_->count()))); + } + if (request_cache_control.min_fresh_.has_value()) { + fields.push_back( + absl::StrCat("min-fresh=", std::to_string(request_cache_control.min_fresh_->count()))); + } + if (request_cache_control.max_stale_.has_value()) { + fields.push_back( + absl::StrCat("max-stale=", std::to_string(request_cache_control.max_stale_->count()))); + } + + return os << "{" << absl::StrJoin(fields, ", ") << "}"; +} + +std::ostream& operator<<(std::ostream& os, const ResponseCacheControl& response_cache_control) { + std::vector fields; + + if (response_cache_control.must_validate_) { + fields.push_back("must_validate"); + } + if (response_cache_control.no_store_) { + fields.push_back("no_store"); + } + if (response_cache_control.no_transform_) { + fields.push_back("no_transform"); + } + if (response_cache_control.no_stale_) { + fields.push_back("no_stale"); + } + if (response_cache_control.is_public_) { + fields.push_back("public"); + } + if (response_cache_control.max_age_.has_value()) { + fields.push_back( + absl::StrCat("max-age=", std::to_string(response_cache_control.max_age_->count()))); + } + + return os << "{" << absl::StrJoin(fields, ", ") << "}"; +} + +SystemTime CacheHeadersUtils::httpTime(const Http::HeaderEntry* header_entry) { + if (!header_entry) { + return {}; + } + absl::Time time; + const absl::string_view input(header_entry->value().getStringView()); + + // Acceptable Date/Time Formats per: + // https://tools.ietf.org/html/rfc7231#section-7.1.1.1 + // + // Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate. + // Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format. + // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format. + static constexpr absl::string_view rfc7231_date_formats[] = { + "%a, %d %b %Y %H:%M:%S GMT", "%A, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"}; + + for (absl::string_view format : rfc7231_date_formats) { + if (absl::ParseTime(format, input, &time, nullptr)) { + return ToChronoTime(time); + } + } + return {}; +} + +Seconds CacheHeadersUtils::calculateAge(const Http::ResponseHeaderMap& response_headers, + const SystemTime response_time, const SystemTime now) { + // Age headers calculations follow: https://httpwg.org/specs/rfc7234.html#age.calculations + const SystemTime date_value = CacheHeadersUtils::httpTime(response_headers.Date()); + + long age_value; + const absl::string_view age_header = response_headers.getInlineValue(CacheCustomHeaders::age()); + if (!absl::SimpleAtoi(age_header, &age_value)) { + age_value = 0; + } + + const SystemTime::duration apparent_age = + std::max(SystemTime::duration(0), response_time - date_value); + + // Assumption: response_delay is negligible -> corrected_age_value = age_value. + const SystemTime::duration corrected_age_value = Seconds(age_value); + const SystemTime::duration corrected_initial_age = std::max(apparent_age, corrected_age_value); + + // Calculate current_age: + const SystemTime::duration resident_time = now - response_time; + const SystemTime::duration current_age = corrected_initial_age + resident_time; + + return std::chrono::duration_cast(current_age); +} + +void CacheHeadersUtils::injectValidationHeaders( + Http::RequestHeaderMap& request_headers, const Http::ResponseHeaderMap& old_response_headers) { + const Http::HeaderEntry* etag_header = old_response_headers.getInline(CacheCustomHeaders::etag()); + const Http::HeaderEntry* last_modified_header = + old_response_headers.getInline(CacheCustomHeaders::lastModified()); + + if (etag_header) { + absl::string_view etag = etag_header->value().getStringView(); + request_headers.setInline(CacheCustomHeaders::ifNoneMatch(), etag); + } + if (DateUtil::timePointValid(CacheHeadersUtils::httpTime(last_modified_header))) { + // Valid Last-Modified header exists. + absl::string_view last_modified = last_modified_header->value().getStringView(); + request_headers.setInline(CacheCustomHeaders::ifModifiedSince(), last_modified); + } else { + // Either Last-Modified is missing or invalid, fallback to Date. + // A correct behaviour according to: + // https://httpwg.org/specs/rfc7232.html#header.if-modified-since + absl::string_view date = old_response_headers.getDateValue(); + request_headers.setInline(CacheCustomHeaders::ifModifiedSince(), date); + } +} + +// TODO(yosrym93): Write a test that exercises this when SimpleHttpCache implements updateHeaders +bool CacheHeadersUtils::shouldUpdateCachedEntry(const Http::ResponseHeaderMap& new_headers, + const Http::ResponseHeaderMap& old_headers) { + ASSERT(Http::Utility::getResponseStatus(new_headers) == enumToInt(Http::Code::NotModified), + "shouldUpdateCachedEntry must only be called with 304 responses"); + + // According to: https://httpwg.org/specs/rfc7234.html#freshening.responses, + // and assuming a single cached response per key: + // If the 304 response contains a strong validator (etag) that does not match the cached response, + // the cached response should not be updated. + const Http::HeaderEntry* response_etag = new_headers.getInline(CacheCustomHeaders::etag()); + const Http::HeaderEntry* cached_etag = old_headers.getInline(CacheCustomHeaders::etag()); + return !response_etag || (cached_etag && cached_etag->value().getStringView() == + response_etag->value().getStringView()); +} + +Key CacheHeadersUtils::makeKey(const Http::RequestHeaderMap& request_headers, + absl::string_view cluster_name) { + ASSERT(request_headers.Path(), "Can't form cache lookup key for malformed Http::RequestHeaderMap " + "with null Path."); + ASSERT(request_headers.Host(), "Can't form cache lookup key for malformed Http::RequestHeaderMap " + "with null Host."); + Key key; + absl::string_view scheme = request_headers.getSchemeValue(); + ASSERT(Http::Utility::schemeIsValid(scheme)); + // TODO(toddmgreer): Let config determine whether to include scheme, host, and + // query params. + key.set_cluster_name(cluster_name); + key.set_host(std::string(request_headers.getHostValue())); + key.set_path(std::string(request_headers.getPathValue())); + if (Http::Utility::schemeIsHttp(scheme)) { + key.set_scheme(Key::HTTP); + } else if (Http::Utility::schemeIsHttps(scheme)) { + key.set_scheme(Key::HTTPS); + } + return key; +} + +absl::optional CacheHeadersUtils::readAndRemoveLeadingDigits(absl::string_view& str) { + uint64_t val = 0; + uint32_t bytes_consumed = 0; + + for (const char cur : str) { + if (!absl::ascii_isdigit(cur)) { + break; + } + uint64_t new_val = (val * 10) + (cur - '0'); + if (new_val / 8 < val) { + // Overflow occurred + return absl::nullopt; + } + val = new_val; + ++bytes_consumed; + } + + if (bytes_consumed) { + // Consume some digits + str.remove_prefix(bytes_consumed); + return val; + } + return absl::nullopt; +} + +void CacheHeadersUtils::getAllMatchingHeaderNames( + const Http::HeaderMap& headers, const std::vector& ruleset, + absl::flat_hash_set& out) { + headers.iterate([&ruleset, &out](const Http::HeaderEntry& header) -> Http::HeaderMap::Iterate { + absl::string_view header_name = header.key().getStringView(); + for (const auto& rule : ruleset) { + if (rule->match(header_name)) { + out.emplace(header_name); + break; + } + } + return Http::HeaderMap::Iterate::Continue; + }); +} + +std::vector +CacheHeadersUtils::parseCommaDelimitedHeader(const Http::HeaderMap::GetResult& entry) { + std::vector values; + for (size_t i = 0; i < entry.size(); ++i) { + std::vector tokens = + Http::HeaderUtility::parseCommaDelimitedHeader(entry[i]->value().getStringView()); + values.insert(values.end(), tokens.begin(), tokens.end()); + } + return values; +} + +VaryAllowList::VaryAllowList( + const Protobuf::RepeatedPtrField& allow_list, + Server::Configuration::CommonFactoryContext& context) { + + for (const auto& rule : allow_list) { + allow_list_.emplace_back(std::make_unique(rule, context)); + } +} + +bool VaryAllowList::allowsValue(const absl::string_view vary_value) const { + for (const auto& rule : allow_list_) { + if (rule->match(vary_value)) { + return true; + } + } + return false; +} + +bool VaryAllowList::allowsHeaders(const Http::ResponseHeaderMap& headers) const { + if (!VaryHeaderUtils::hasVary(headers)) { + return true; + } + + std::vector varied_headers = + CacheHeadersUtils::parseCommaDelimitedHeader(headers.get(Http::CustomHeaders::get().Vary)); + + for (absl::string_view& header : varied_headers) { + bool valid = false; + + // "Vary: *" should never be cached per: + // https://tools.ietf.org/html/rfc7231#section-7.1.4 + if (header == "*") { + return false; + } + + if (allowsValue(header)) { + valid = true; + } + + if (!valid) { + return false; + } + } + + return true; +} + +bool VaryHeaderUtils::hasVary(const Http::ResponseHeaderMap& headers) { + // TODO(mattklein123): Support multiple vary headers and/or just make the vary header inline. + const auto vary_header = headers.get(Http::CustomHeaders::get().Vary); + return !vary_header.empty() && !vary_header[0]->value().empty(); +} + +absl::btree_set +VaryHeaderUtils::getVaryValues(const Http::ResponseHeaderMap& headers) { + Http::HeaderMap::GetResult vary_headers = headers.get(Http::CustomHeaders::get().Vary); + if (vary_headers.empty()) { + return {}; + } + + std::vector values = + CacheHeadersUtils::parseCommaDelimitedHeader(vary_headers); + return {values.begin(), values.end()}; +} + +namespace { +// The separator characters are used to create the vary-key, and must be characters that are +// invalid to be inside values and header names. The chosen characters are invalid per: +// https://tools.ietf.org/html/rfc2616#section-4.2. + +// Used to separate the values of different headers. +constexpr absl::string_view headerSeparator = "\n"; +// Used to separate multiple values of a same header. +constexpr absl::string_view inValueSeparator = "\r"; +}; // namespace + +absl::optional +VaryHeaderUtils::createVaryIdentifier(const VaryAllowList& allow_list, + const absl::btree_set& vary_header_values, + const Http::RequestHeaderMap& request_headers) { + std::string vary_identifier = "vary-id\n"; + if (vary_header_values.empty()) { + return vary_identifier; + } + + for (const absl::string_view& value : vary_header_values) { + if (value.empty()) { + // Empty headers are ignored. + continue; + } + if (!allow_list.allowsValue(value)) { + // The backend tried to vary on a header that we don't allow, so return + // absl::nullopt to indicate we are unable to cache this request. This + // also may occur if the allow list has changed since an item was cached, + // rendering the cached vary value invalid. + return absl::nullopt; + } + // TODO(cbdm): Can add some bucketing logic here based on header. For + // example, we could normalize the values for accept-language by making all + // of {en-CA, en-GB, en-US} into "en". This way we would not need to store + // multiple versions of the same payload, and any of those values would find + // the payload in the requested language. Another example would be to bucket + // UserAgent values into android/ios/desktop; + // UserAgent::initializeFromHeaders tries to do that normalization and could + // be used as an inspiration for some bucketing configuration. The config + // should enable and control the bucketing wanted. + const auto all_values = Http::HeaderUtility::getAllOfHeaderAsString( + request_headers, Http::LowerCaseString(std::string(value)), inValueSeparator); + absl::StrAppend(&vary_identifier, value, inValueSeparator, + all_values.result().has_value() ? all_values.result().value() : "", + headerSeparator); + } + + return vary_identifier; +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_headers_utils.h b/source/extensions/filters/http/cache_v2/cache_headers_utils.h new file mode 100644 index 0000000000000..0f92e26e8985a --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_headers_utils.h @@ -0,0 +1,187 @@ +#pragma once + +#include + +#include "envoy/common/time.h" +#include "envoy/extensions/filters/http/cache_v2/v3/cache.pb.h" +#include "envoy/http/header_map.h" + +#include "source/common/common/matchers.h" +#include "source/common/http/header_map_impl.h" +#include "source/common/http/header_utility.h" +#include "source/common/http/headers.h" +#include "source/common/protobuf/protobuf.h" +#include "source/extensions/filters/http/cache_v2/key.pb.h" + +#include "absl/container/btree_set.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +using OptionalDuration = absl::optional; + +// According to: https://httpwg.org/specs/rfc7234.html#cache-request-directive +struct RequestCacheControl { + RequestCacheControl() = default; + explicit RequestCacheControl(absl::string_view cache_control_header); + + // must_validate is true if 'no-cache' directive is present + // A cached response must not be served without successful validation with the origin + bool must_validate_ = false; + + // The response to this request must not be cached (stored) + bool no_store_ = false; + + // 'no-transform' directive is not used now + // No transformations should be done to the response of this request, as defined by: + // https://httpwg.org/specs/rfc7230.html#message.transformations + bool no_transform_ = false; + + // 'only-if-cached' directive is not used now + // The request should be satisfied using a cached response, or respond with 504 (Gateway Error) + bool only_if_cached_ = false; + + // The client is unwilling to receive a cached response whose age exceeds the max-age + OptionalDuration max_age_; + + // The client is unwilling to received a cached response that satisfies: + // expiration_time - now < min-fresh + OptionalDuration min_fresh_; + + // The client is willing to receive a stale response that satisfies: + // now - expiration_time < max-stale + // If max-stale has no value then the client is willing to receive any stale response + OptionalDuration max_stale_; +}; + +// According to: https://httpwg.org/specs/rfc7234.html#cache-response-directive +struct ResponseCacheControl { + ResponseCacheControl() = default; + explicit ResponseCacheControl(absl::string_view cache_control_header); + + // must_validate is true if 'no-cache' directive is present; arguments are ignored for now + // This response must not be used to satisfy subsequent requests without successful validation + // with the origin + bool must_validate_ = false; + + // no_store is true if any of 'no-store' or 'private' directives is present. + // 'private' arguments are ignored for now so it is equivalent to 'no-store' + // This response must not be cached (stored) + bool no_store_ = false; + + // 'no-transform' directive is not used now + // No transformations should be done to this response , as defined by: + // https://httpwg.org/specs/rfc7230.html#message.transformations + bool no_transform_ = false; + + // no_stale is true if any of 'must-revalidate' or 'proxy-revalidate' directives is present + // This response must not be served stale without successful validation with the origin + bool no_stale_ = false; + + // 'public' directive is not used now + // This response may be stored, even if the response would normally be non-cacheable or cacheable + // only within a private cache, see: + // https://httpwg.org/specs/rfc7234.html#cache-response-directive.public + bool is_public_ = false; + + // max_age is set if to 's-maxage' if present, if not it is set to 'max-age' if present. + // Indicates the maximum time after which this response will be considered stale + OptionalDuration max_age_; +}; + +bool operator==(const RequestCacheControl& lhs, const RequestCacheControl& rhs); +bool operator==(const ResponseCacheControl& lhs, const ResponseCacheControl& rhs); +std::ostream& operator<<(std::ostream& os, const RequestCacheControl& request_cache_control); +std::ostream& operator<<(std::ostream& os, const ResponseCacheControl& response_cache_control); + +namespace CacheHeadersUtils { +// Parses header_entry as an HTTP time. Returns SystemTime() if +// header_entry is null or malformed. +SystemTime httpTime(const Http::HeaderEntry* header_entry); + +// Calculates the age of a cached response +Seconds calculateAge(const Http::ResponseHeaderMap& response_headers, SystemTime response_time, + SystemTime now); + +// Create a resource key from headers and cluster name. +Key makeKey(const Http::RequestHeaderMap& request_headers, absl::string_view cluster_name); + +// Adds required conditional headers for cache validation to the request headers +// according to the previous response headers. +void injectValidationHeaders(Http::RequestHeaderMap& request_headers, + const Http::ResponseHeaderMap& old_response_headers); + +// Checks if a cached entry should be updated with a 304 response. +bool shouldUpdateCachedEntry(const Http::ResponseHeaderMap& new_headers, + const Http::ResponseHeaderMap& old_headers); + +/** + * Read a leading positive decimal integer value and advance "*str" past the + * digits read. If overflow occurs, or no digits exist, return + * absl::nullopt without advancing "*str". + */ +absl::optional readAndRemoveLeadingDigits(absl::string_view& str); + +// Add to out all header names from the given map that match any of the given rules. +void getAllMatchingHeaderNames(const Http::HeaderMap& headers, + const std::vector& ruleset, + absl::flat_hash_set& out); + +// Parses the values of a comma-delimited list as defined per +// https://tools.ietf.org/html/rfc7230#section-7. +std::vector parseCommaDelimitedHeader(const Http::HeaderMap::GetResult& entry); +} // namespace CacheHeadersUtils + +// Helper abstraction for a container that contains a VaryAllowList. +class CacheableResponseChecker { +public: + // Calls CacheabilityUtils::isCacheableResponse with the contained VaryAllowList. + virtual bool isCacheableResponse(const Http::ResponseHeaderMap& headers) const PURE; + virtual ~CacheableResponseChecker() = default; +}; + +class VaryAllowList { +public: + // Parses the allow list from the Cache Config into the object's private allow_list_. + VaryAllowList( + const Protobuf::RepeatedPtrField& allow_list, + Server::Configuration::CommonFactoryContext& context); + + // Checks if the headers contain an allowed value in the Vary header. + bool allowsHeaders(const Http::ResponseHeaderMap& headers) const; + + // Checks if this vary header value is allowed to vary cache entries. + bool allowsValue(const absl::string_view header) const; + +private: + // Stores the matching rules that define whether a header is allowed to be varied. + std::vector allow_list_; +}; + +namespace VaryHeaderUtils { +// Checks if the headers contain a non-empty value in the Vary header. +bool hasVary(const Http::ResponseHeaderMap& headers); + +// Retrieve all the individual header values from the provided response header +// map across all vary header entries. +absl::btree_set getVaryValues(const Envoy::Http::ResponseHeaderMap& headers); + +// Creates a single string combining the values of the varied headers from +// entry_headers. Returns an absl::nullopt if no valid vary key can be created +// and the response should not be cached (eg. when disallowed vary headers are +// present in the response). +absl::optional +createVaryIdentifier(const VaryAllowList& allow_list, + const absl::btree_set& vary_header_values, + const Envoy::Http::RequestHeaderMap& request_headers); +} // namespace VaryHeaderUtils + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_policy.h b/source/extensions/filters/http/cache_v2/cache_policy.h new file mode 100644 index 0000000000000..f1f23bcd36a19 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_policy.h @@ -0,0 +1,163 @@ +#pragma once + +#include + +#include "envoy/http/header_map.h" +#include "envoy/stream_info/filter_state.h" + +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +/** + * Contains information about whether the cache entry is usable. + */ +struct CacheEntryUsability { + /** + * Whether the cache entry is usable, additional checks are required to be usable, or unusable. + */ + CacheEntryStatus status = CacheEntryStatus::Unusable; + /** + * Value to be put in the Age header for cache responses. + */ + Seconds age = Seconds::max(); + /** + * Remaining freshness lifetime--how long from now until the response is stale. (If the response + * is already stale, `ttl` should be negative.) + */ + Seconds ttl = Seconds::max(); + + friend bool operator==(const CacheEntryUsability& a, const CacheEntryUsability& b) { + return std::tie(a.status, a.age, a.ttl) == std::tie(b.status, b.age, b.ttl); + } + + friend bool operator!=(const CacheEntryUsability& a, const CacheEntryUsability& b) { + return !(a == b); + } +}; + +enum class RequestCacheability { + // This request is eligible for serving from cache, and for having its response stored. + Cacheable, + // Don't respond to this request from cache, or store its response into cache. + Bypass, + // This request is eligible for serving from cache, but its response + // must not be stored. Consider the following sequence: + // - Request 1: "curl http://example.com/ -H 'cache-control: no-store'" + // - `requestCacheability` returns `NoStore`. + // - CacheFilter finds nothing in cache, so request 1 is proxied upstream. + // - Origin responds with a cacheable response 1. + // - CacheFilter does not store response 1 into cache. + // - Request 2: "curl http://example.com/" + // - `requestCacheability` returns `Cacheable`. + // - CacheFilter finds nothing in cache, so request 2 is proxied upstream. + // - Origin responds with a cacheable response 2. + // - CacheFilter stores response 2 into cache. + // - Request 3: "curl http://example.com/ -H 'cache-control: no-store'" + // - `requestCacheability` returns `NoStore`. + // - CacheFilter looks in cache and finds response 2, which matches. + // - CacheFilter serves response 2 from cache. + // To summarize, all 3 requests were eligible for serving from cache (though only request 3 found + // a match to serve), but only request 2 was allowed to have its response stored into cache. + NoStore, +}; + +inline std::ostream& operator<<(std::ostream& os, RequestCacheability cacheability) { + switch (cacheability) { + using enum RequestCacheability; + case Cacheable: + return os << "Cacheable"; + case Bypass: + return os << "Bypass"; + case NoStore: + return os << "NoStore"; + } +} + +enum class ResponseCacheability { + // Don't store this response in cache. + DoNotStore, + // Store the full response in cache. + StoreFullResponse, + // Store a cache entry indicating that the response was uncacheable, and that future responses are + // likely to be uncacheable. (CacheFilter and/or HttpCache implementations will treat such entries + // as cache misses, but may enable optimizations based on expecting uncacheable responses. If a + // future response is cacheable, it will overwrite this "uncacheable" entry.) + MarkUncacheable, +}; + +inline std::ostream& operator<<(std::ostream& os, ResponseCacheability cacheability) { + switch (cacheability) { + using enum ResponseCacheability; + case DoNotStore: + return os << "DoNotStore"; + case StoreFullResponse: + return os << "StoreFullResponse"; + case MarkUncacheable: + return os << "MarkUncacheable"; + } +} + +// Create cache key, calculate cache content freshness and +// response cacheability. This can be a straight RFC compliant implementation +// but can also be used to implement deployment specific cache policies. +// +// NOT YET IMPLEMENTED: To make CacheFilter use a custom cache policy, store a mutable CachePolicy +// in FilterState before CacheFilter::decodeHeaders is called. +class CachePolicy : public StreamInfo::FilterState::Object { +public: + // For use in FilterState. + static constexpr absl::string_view Name = "envoy.extensions.filters.http.cache_v2.cache_policy"; + + virtual ~CachePolicy() = default; + + /** + * Calculates the lookup key for storing the entry in the cache. + * @param request_headers - headers from the request the CacheFilter is currently processing. + */ + virtual Key cacheKey(const Http::RequestHeaderMap& request_headers) PURE; + + /** + * Determines whether the request is eligible for serving from cache and/or having its response + * stored in cache. + * @param request_headers - headers from the request the CacheFilter is currently processing. + * @return an enum indicating whether the request is eligible for serving from cache and/or having + * its response stored in cache. + */ + virtual RequestCacheability + requestCacheability(const Http::RequestHeaderMap& request_headers) PURE; + + /** + * Determines the cacheability of the response during encoding. + * @param request_headers - headers from the request the CacheFilter is currently processing. + * @param response_headers - headers from the upstream response the CacheFilter is currently + * processing. + * @return an enum indicating how the response should be handled. + */ + virtual ResponseCacheability + responseCacheability(const Http::RequestHeaderMap& request_headers, + const Http::ResponseHeaderMap& response_headers) PURE; + + /** + * Determines whether the cached entry may be used directly or must be validated with upstream. + * @param request_headers - request headers associated with the response_headers. + * @param cached_response_headers - headers from the cached response. + * @param content_length - the byte length of the cached content. + * @param cached_metadata - the metadata that has been stored along side the cached entry. + * @param now - the timestamp for this request. + * @return details about whether or not the cached entry can be used. + */ + virtual CacheEntryUsability + cacheEntryUsability(const Http::RequestHeaderMap& request_headers, + const Http::ResponseHeaderMap& cached_response_headers, + const uint64_t content_length, const ResponseMetadata& cached_metadata, + SystemTime now) PURE; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_progress_receiver.h b/source/extensions/filters/http/cache_v2/cache_progress_receiver.h new file mode 100644 index 0000000000000..2d0005990d20f --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_progress_receiver.h @@ -0,0 +1,27 @@ +#pragma once + +#include "envoy/http/header_map.h" + +#include "source/extensions/filters/http/cache_v2/range_utils.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class CacheReader; + +class CacheProgressReceiver { +public: + virtual void onHeadersInserted(std::unique_ptr cache_entry, + Http::ResponseHeaderMapPtr headers, bool end_stream) PURE; + virtual void onBodyInserted(AdjustedByteRange range, bool end_stream) PURE; + virtual void onTrailersInserted(Http::ResponseTrailerMapPtr trailers) PURE; + virtual void onInsertFailed(absl::Status status) PURE; + virtual ~CacheProgressReceiver() = default; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_sessions.cc b/source/extensions/filters/http/cache_v2/cache_sessions.cc new file mode 100644 index 0000000000000..079bd6d9a6c52 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_sessions.cc @@ -0,0 +1,111 @@ +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" + +#include + +#include "source/common/http/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +ActiveLookupRequest::ActiveLookupRequest( + const Http::RequestHeaderMap& request_headers, + UpstreamRequestFactoryPtr upstream_request_factory, absl::string_view cluster_name, + Event::Dispatcher& dispatcher, SystemTime timestamp, + const std::shared_ptr cacheable_response_checker, + const std::shared_ptr stats_provider, + bool ignore_request_cache_control_header) + : upstream_request_factory_(std::move(upstream_request_factory)), dispatcher_(dispatcher), + key_(CacheHeadersUtils::makeKey(request_headers, cluster_name)), + request_headers_(Http::createHeaderMap(request_headers)), + cacheable_response_checker_(std::move(cacheable_response_checker)), + stats_provider_(std::move(stats_provider)), timestamp_(timestamp) { + if (!ignore_request_cache_control_header) { + initializeRequestCacheControl(request_headers); + } +} + +absl::optional> ActiveLookupRequest::parseRange() const { + auto range_header = RangeUtils::getRangeHeader(*request_headers_); + if (!range_header) { + return absl::nullopt; + } + return RangeUtils::parseRangeHeader(range_header.value(), 1); +} + +bool ActiveLookupRequest::isRangeRequest() const { + return RangeUtils::getRangeHeader(*request_headers_).has_value(); +} + +void ActiveLookupRequest::initializeRequestCacheControl( + const Http::RequestHeaderMap& request_headers) { + const absl::string_view cache_control = + request_headers.getInlineValue(CacheCustomHeaders::requestCacheControl()); + + if (!cache_control.empty()) { + request_cache_control_ = RequestCacheControl(cache_control); + } else { + const absl::string_view pragma = request_headers.getInlineValue(CacheCustomHeaders::pragma()); + // According to: https://httpwg.org/specs/rfc7234.html#header.pragma, + // when Cache-Control header is missing, "Pragma:no-cache" is equivalent to + // "Cache-Control:no-cache". Any other directives are ignored. + request_cache_control_.must_validate_ = RequestCacheControl(pragma).must_validate_; + } +} + +bool ActiveLookupRequest::requiresValidation(const Http::ResponseHeaderMap& response_headers, + SystemTime::duration response_age) const { + // TODO(yosrym93): Store parsed response cache-control in cache instead of parsing it on every + // lookup. + const absl::string_view cache_control = + response_headers.getInlineValue(CacheCustomHeaders::responseCacheControl()); + const ResponseCacheControl response_cache_control(cache_control); + + const bool request_max_age_exceeded = request_cache_control_.max_age_.has_value() && + request_cache_control_.max_age_.value() < response_age; + if (response_cache_control.must_validate_ || request_cache_control_.must_validate_ || + request_max_age_exceeded) { + // Either the request or response explicitly require validation, or a request max-age + // requirement is not satisfied. + return true; + } + + // CacheabilityUtils::isCacheableResponse(..) guarantees that any cached response satisfies this. + ASSERT(response_cache_control.max_age_.has_value() || + (response_headers.getInline(CacheCustomHeaders::expires()) && response_headers.Date()), + "Cache entry does not have valid expiration data."); + + SystemTime::duration freshness_lifetime; + if (response_cache_control.max_age_.has_value()) { + freshness_lifetime = response_cache_control.max_age_.value(); + } else { + const SystemTime expires_value = + CacheHeadersUtils::httpTime(response_headers.getInline(CacheCustomHeaders::expires())); + const SystemTime date_value = CacheHeadersUtils::httpTime(response_headers.Date()); + freshness_lifetime = expires_value - date_value; + } + + if (response_age > freshness_lifetime) { + // Response is stale, requires validation if + // the response does not allow being served stale, + // or the request max-stale directive does not allow it. + const bool allowed_by_max_stale = + request_cache_control_.max_stale_.has_value() && + request_cache_control_.max_stale_.value() > response_age - freshness_lifetime; + return response_cache_control.no_stale_ || !allowed_by_max_stale; + } else { + // Response is fresh, requires validation only if there is an unsatisfied min-fresh requirement. + const bool min_fresh_unsatisfied = + request_cache_control_.min_fresh_.has_value() && + request_cache_control_.min_fresh_.value() > freshness_lifetime - response_age; + return min_fresh_unsatisfied; + } +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_sessions.h b/source/extensions/filters/http/cache_v2/cache_sessions.h new file mode 100644 index 0000000000000..818c1dcf08ef6 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_sessions.h @@ -0,0 +1,105 @@ +#pragma once + +#include + +#include "envoy/buffer/buffer.h" + +#include "source/extensions/filters/http/cache_v2/http_cache.h" +#include "source/extensions/filters/http/cache_v2/key.pb.h" +#include "source/extensions/filters/http/cache_v2/stats.h" +#include "source/extensions/filters/http/cache_v2/upstream_request.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class ActiveLookupRequest { +public: + // Prereq: request_headers's Path(), Scheme(), and Host() are non-null. + ActiveLookupRequest( + const Http::RequestHeaderMap& request_headers, + UpstreamRequestFactoryPtr upstream_request_factory, absl::string_view cluster_name, + Event::Dispatcher& dispatcher, SystemTime timestamp, + const std::shared_ptr cacheable_response_checker_, + const std::shared_ptr stats_provider_, + bool ignore_request_cache_control_header); + + // 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_; } + + Http::RequestHeaderMap& requestHeaders() const { return *request_headers_; } + bool isCacheableResponse(const Http::ResponseHeaderMap& headers) const { + return cacheable_response_checker_->isCacheableResponse(headers); + } + const std::shared_ptr& cacheableResponseChecker() const { + return cacheable_response_checker_; + } + const std::shared_ptr& statsProvider() const { + return stats_provider_; + } + CacheFilterStats& stats() const { return statsProvider()->stats(); } + UpstreamRequestPtr createUpstreamRequest() const { + return upstream_request_factory_->create(statsProvider()); + } + Event::Dispatcher& dispatcher() const { return dispatcher_; } + SystemTime timestamp() const { return timestamp_; } + bool requiresValidation(const Http::ResponseHeaderMap& response_headers, + SystemTime::duration age) const; + absl::optional> parseRange() const; + bool isRangeRequest() const; + +private: + void initializeRequestCacheControl(const Http::RequestHeaderMap& request_headers); + + UpstreamRequestFactoryPtr upstream_request_factory_; + Event::Dispatcher& dispatcher_; + Key key_; + std::vector request_range_spec_; + Http::RequestHeaderMapPtr request_headers_; + const std::shared_ptr cacheable_response_checker_; + const std::shared_ptr stats_provider_; + // Time when this LookupRequest was created (in response to an HTTP request). + SystemTime timestamp_; + RequestCacheControl request_cache_control_; +}; +using ActiveLookupRequestPtr = std::unique_ptr; + +struct ActiveLookupResult { + // The source from which headers, body and trailers can be retrieved. May be + // a cache-reader CacheSession, or may be an UpstreamRequest if the request + // was uncacheable. The filter doesn't need to know which. + std::unique_ptr http_source_; + + CacheEntryStatus status_; +}; + +using ActiveLookupResultPtr = std::unique_ptr; +using ActiveLookupResultCallback = absl::AnyInvocable; + +// CacheSessions is a wrapper around an HttpCache which provides a shorter-lived in-memory +// cache of headers and already open cache entries. All the http-specific aspects of the +// cache (range requests, validation, etc.) are performed by the CacheSession +// so the HttpCache only needs to support simple read/write operations. +// +// May or may not be a singleton, depending on the specific cache extension; must include +// the Singleton::Instance interface to support cases when it is. +class CacheSessions : public Singleton::Instance, public CacheFilterStatsProvider { +public: + // This is implemented in CacheSessionsImpl so that tests which only use a mock don't + // need to build the real thing, but declared here so that the actual use-site can + // create an instance without including the larger header. + static std::shared_ptr create(Server::Configuration::FactoryContext& context, + std::unique_ptr cache); + + virtual void lookup(ActiveLookupRequestPtr request, ActiveLookupResultCallback&& cb) PURE; + virtual HttpCache& cache() const PURE; + CacheInfo cacheInfo() const { return cache().cacheInfo(); } + ~CacheSessions() override = default; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_sessions_impl.cc b/source/extensions/filters/http/cache_v2/cache_sessions_impl.cc new file mode 100644 index 0000000000000..88cb0bd27d265 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_sessions_impl.cc @@ -0,0 +1,917 @@ +#include "source/extensions/filters/http/cache_v2/cache_sessions_impl.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/common/enum_to_int.h" +#include "source/common/http/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/cacheability_utils.h" +#include "source/extensions/filters/http/cache_v2/range_utils.h" +#include "source/extensions/filters/http/cache_v2/upstream_request.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +using CancelWrapper::cancelWrapped; + +class UpstreamRequestWithCacheabilityReset : public HttpSource { +public: + UpstreamRequestWithCacheabilityReset( + std::shared_ptr cacheable_response_checker, + std::unique_ptr original_source, std::shared_ptr entry) + : cacheable_response_checker_(cacheable_response_checker), + original_source_(std::move(original_source)), entry_(std::move(entry)) {} + void getHeaders(GetHeadersCallback&& cb) override { + original_source_->getHeaders( + [entry = std::move(entry_), cb = std::move(cb), + cacheable_response_checker = std::move(cacheable_response_checker_)]( + Http::ResponseHeaderMapPtr headers, EndStream end_stream) mutable { + if (cacheable_response_checker->isCacheableResponse(*headers)) { + entry->clearUncacheableState(); + } + cb(std::move(headers), end_stream); + }); + } + void getBody(AdjustedByteRange range, GetBodyCallback&& cb) override { + original_source_->getBody(std::move(range), std::move(cb)); + } + void getTrailers(GetTrailersCallback&& cb) override { + original_source_->getTrailers(std::move(cb)); + } + +private: + std::shared_ptr cacheable_response_checker_; + std::unique_ptr original_source_; + std::shared_ptr entry_; +}; + +class UpstreamRequestWithHeadersPrepopulated : public HttpSource { +public: + UpstreamRequestWithHeadersPrepopulated(std::unique_ptr original_source, + Http::ResponseHeaderMapPtr headers, EndStream end_stream) + : original_source_(std::move(original_source)), headers_(std::move(headers)), + end_stream_after_headers_(end_stream) {} + void getHeaders(GetHeadersCallback&& cb) override { + cb(std::move(headers_), end_stream_after_headers_); + } + void getBody(AdjustedByteRange range, GetBodyCallback&& cb) override { + original_source_->getBody(std::move(range), std::move(cb)); + } + void getTrailers(GetTrailersCallback&& cb) override { + original_source_->getTrailers(std::move(cb)); + } + +private: + std::unique_ptr original_source_; + Http::ResponseHeaderMapPtr headers_; + EndStream end_stream_after_headers_; +}; + +static Http::RequestHeaderMapPtr +requestHeadersWithRangeRemoved(const Http::RequestHeaderMap& original_headers) { + Http::RequestHeaderMapPtr headers = + Http::createHeaderMap(original_headers); + headers->remove(Envoy::Http::Headers::get().Range); + return headers; +} + +static Http::ResponseHeaderMapPtr notSatisfiableHeaders() { + static const std::string not_satisfiable = + std::to_string(enumToInt(Http::Code::RangeNotSatisfiable)); + return Http::createHeaderMap({ + {Http::Headers::get().Status, not_satisfiable}, + {Http::Headers::get().ContentLength, "0"}, + }); +} + +void ActiveLookupContext::getHeaders(GetHeadersCallback&& cb) { + absl::optional> ranges = lookup().parseRange(); + if (ranges) { + // If it's a range request, inject the appropriate modified content-range and + // content-length headers into the response once we have the response headers. + entry_->wantHeaders( + dispatcher(), lookup().timestamp(), + [ranges = std::move(ranges.value()), cl = content_length_, + cb = std::move(cb)](Http::ResponseHeaderMapPtr headers, EndStream end_stream) mutable { + ASSERT(headers != nullptr, "it should be impossible for headers to be null"); + if (cl == 0 && headers->ContentLength()) { + absl::SimpleAtoi(headers->getContentLengthValue(), &cl) || (cl = 0); + } + RangeDetails range_details = RangeUtils::createAdjustedRangeDetails(ranges, cl); + if (!range_details.satisfiable_) { + return cb(notSatisfiableHeaders(), EndStream::End); + } + if (range_details.ranges_.empty()) { + return cb(std::move(headers), end_stream); + } + auto& range = range_details.ranges_[0]; + headers->setReferenceKey( + Envoy::Http::Headers::get().ContentRange, + fmt::format("bytes {}-{}/{}", range.begin(), range.end() - 1, cl)); + headers->setContentLength(range.length()); + static const std::string partial_content = + std::to_string(enumToInt(Http::Code::PartialContent)); + headers->setStatus(partial_content); + cb(std::move(headers), end_stream); + }); + } else { + entry_->wantHeaders(dispatcher(), lookup().timestamp(), std::move(cb)); + } +} + +void ActiveLookupContext::getBody(AdjustedByteRange range, GetBodyCallback&& cb) { + entry_->wantBodyRange(range, dispatcher(), std::move(cb)); +} + +void ActiveLookupContext::getTrailers(GetTrailersCallback&& cb) { + entry_->wantTrailers(dispatcher(), std::move(cb)); +} + +std::shared_ptr CacheSessions::create(Server::Configuration::FactoryContext& context, + std::unique_ptr cache) { + return std::make_shared(context, std::move(cache)); +} + +CacheSession::CacheSession(std::weak_ptr cache_sessions, const Key& key) + : cache_sessions_(std::move(cache_sessions)), key_(key) {} + +void CacheSession::clearUncacheableState() { + absl::MutexLock lock(&mu_); + if (state_ != State::NotCacheable) { + return; + } + state_ = State::New; +} + +void CacheSession::wantHeaders(Event::Dispatcher&, SystemTime lookup_timestamp, + GetHeadersCallback&& cb) { + Http::ResponseHeaderMapPtr headers; + EndStream end_stream_after_headers; + { + absl::MutexLock lock(&mu_); + ASSERT(entry_.response_headers_ != nullptr, + "headers should have been initialized during lookup"); + headers = Http::createHeaderMap(*entry_.response_headers_); + Seconds age = CacheHeadersUtils::calculateAge( + *headers, entry_.response_metadata_.response_time_, lookup_timestamp); + headers->setReferenceKey(Envoy::Http::CustomHeaders::get().Age, std::to_string(age.count())); + end_stream_after_headers = endStreamAfterHeaders(); + } + cb(std::move(headers), end_stream_after_headers); +} + +void CacheSession::wantBodyRange(AdjustedByteRange range, Event::Dispatcher& dispatcher, + GetBodyCallback&& cb) { + absl::MutexLock lock(&mu_); + ASSERT(entry_.response_headers_ != nullptr, + "body should not be requested when headers haven't been sent"); + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().incCacheSessionsSubscribers(); + } + body_subscribers_.emplace_back(dispatcher, std::move(range), std::move(cb)); + // if there's not already a body read operation in flight, start one. + maybeTriggerBodyReadForWaitingSubscriber(); +} + +void CacheSession::wantTrailers(Event::Dispatcher& dispatcher, GetTrailersCallback&& cb) { + absl::MutexLock lock(&mu_); + if (entry_.response_trailers_ != nullptr) { + auto trailers = Http::createHeaderMap(*entry_.response_trailers_); + dispatcher.post([cb = std::move(cb), trailers = std::move(trailers)]() mutable { + cb(std::move(trailers), EndStream::End); + }); + return; + } + ASSERT(!entry_.body_length_.has_value(), + "wantTrailers should not be called when there are no trailers"); + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().incCacheSessionsSubscribers(); + } + trailer_subscribers_.emplace_back(dispatcher, std::move(cb)); +} + +void CacheSession::onHeadersInserted(CacheReaderPtr cache_reader, + Http::ResponseHeaderMapPtr headers, bool end_stream) { + absl::MutexLock lock(&mu_); + std::shared_ptr cache_sessions = cache_sessions_.lock(); + if (!cache_sessions) { + ENVOY_LOG(error, "cache config was deleted while header-insertion was in flight"); + return onCacheWentAway(); + } + entry_.cache_reader_ = std::move(cache_reader); + entry_.response_headers_ = std::move(headers); + entry_.response_metadata_ = cache_sessions->makeMetadata(); + if (end_stream) { + insertComplete(); + } else { + state_ = State::Inserting; + } + sendLookupResponsesAndMaybeValidationRequest(CacheEntryStatus::Miss); +} + +bool CacheSession::requiresValidationFor(const ActiveLookupRequest& lookup) const { + mu_.AssertHeld(); + const Seconds age = CacheHeadersUtils::calculateAge( + *entry_.response_headers_, entry_.response_metadata_.response_time_, lookup.timestamp()); + return lookup.requiresValidation(*entry_.response_headers_, age); +} + +void CacheSession::sendLookupResponsesAndMaybeValidationRequest(CacheEntryStatus status) { + mu_.AssertHeld(); + ASSERT(state_ == State::Exists || state_ == State::Inserting); + auto it = lookup_subscribers_.begin(); + if (status != CacheEntryStatus::Miss) { + // Reorder subscribers so those who do not require validation are at the end, + // and 'it' is the first subscriber that does not require validation. + it = std::partition(lookup_subscribers_.begin(), lookup_subscribers_.end(), + [this](LookupSubscriber& s) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_) { + return requiresValidationFor(s.context_->lookup()); + }); + } + for (auto recipient = it; recipient != lookup_subscribers_.end(); recipient++) { + sendSuccessfulLookupResultTo(*recipient, status); + // If there was more than one recipient, and the first one was a miss, the + // rest will be streamed. + if (status == CacheEntryStatus::Miss) { + status = CacheEntryStatus::Follower; + } + } + if (it != lookup_subscribers_.end()) { + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().subCacheSessionsSubscribers( + std::distance(it, lookup_subscribers_.end())); + } + } + lookup_subscribers_.erase(it, lookup_subscribers_.end()); + if (!lookup_subscribers_.empty()) { + // At least one subscriber required validation. + return performValidation(); + } +} + +EndStream CacheSession::endStreamAfterHeaders() const { + mu_.AssertHeld(); + bool end_stream = entry_.body_length_.value_or(1) == 0 && entry_.response_trailers_ == nullptr; + return end_stream ? EndStream::End : EndStream::More; +} + +EndStream CacheSession::endStreamAfterBody() const { + mu_.AssertHeld(); + ASSERT(entry_.body_length_.has_value(), + "should not be testing endStreamAfterBody if body not complete"); + return (entry_.response_trailers_ == nullptr) ? EndStream::End : EndStream::More; +} + +void CacheSession::sendSuccessfulLookupResultTo(LookupSubscriber& subscriber, + CacheEntryStatus status) { + mu_.AssertHeld(); + ASSERT(state_ == State::Exists || state_ == State::Inserting); + auto result = std::make_unique(); + result->status_ = status; + result->http_source_ = std::move(subscriber.context_); + subscriber.dispatcher().post( + [result = std::move(result), callback = std::move(subscriber.callback_)]() mutable { + callback(std::move(result)); + }); +} + +void CacheSession::onBodyInserted(AdjustedByteRange range, bool end_stream) { + absl::MutexLock lock(&mu_); + body_length_available_ = range.end(); + if (end_stream) { + insertComplete(); + ASSERT(trailer_subscribers_.empty(), "should not be trailer requests before body was complete"); + } + maybeTriggerBodyReadForWaitingSubscriber(); +} + +void CacheSession::onTrailersInserted(Http::ResponseTrailerMapPtr trailers) { + ASSERT(trailers); + absl::MutexLock lock(&mu_); + entry_.response_trailers_ = std::move(trailers); + insertComplete(); + for (TrailerSubscriber& subscriber : trailer_subscribers_) { + sendTrailersTo(subscriber); + } + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().subCacheSessionsSubscribers(trailer_subscribers_.size()); + } + trailer_subscribers_.clear(); + // If there's a body subscriber waiting for more body that doesn't exist, + // it needs to be notified so it can call getTrailers. + abortBodyOutOfRangeSubscribers(); +} + +void CacheSession::sendTrailersTo(TrailerSubscriber& subscriber) { + mu_.AssertHeld(); + ASSERT(entry_.response_trailers_ != nullptr); + subscriber.dispatcher().post( + [trailers = Http::createHeaderMap(*entry_.response_trailers_), + callback = std::move(subscriber.callback_)]() mutable { + callback(std::move(trailers), EndStream::End); + }); +} + +void CacheSession::onInsertFailed(absl::Status status) { + absl::MutexLock lock(&mu_); + ENVOY_LOG(error, "cache insert failed: {}", status); + onCacheError(); +} + +static void postUpstreamPassThrough(CacheSession::LookupSubscriber&& sub, CacheEntryStatus status) { + Event::Dispatcher& dispatcher = sub.dispatcher(); + dispatcher.post([sub = std::move(sub), status]() mutable { + auto result = std::make_unique(); + auto upstream = sub.context_->lookup().createUpstreamRequest(); + upstream->sendHeaders( + Http::createHeaderMap(sub.context_->lookup().requestHeaders())); + result->http_source_ = std::move(upstream); + result->status_ = status; + sub.callback_(std::move(result)); + }); +} + +static void postUpstreamPassThroughWithReset(CacheSession::LookupSubscriber&& sub, + std::shared_ptr entry) { + Event::Dispatcher& dispatcher = sub.dispatcher(); + dispatcher.post([sub = std::move(sub), entry = std::move(entry)]() mutable { + auto result = std::make_unique(); + auto upstream = sub.context_->lookup().createUpstreamRequest(); + upstream->sendHeaders( + Http::createHeaderMap(sub.context_->lookup().requestHeaders())); + result->http_source_ = std::make_unique( + sub.context_->lookup().cacheableResponseChecker(), std::move(upstream), entry); + result->status_ = CacheEntryStatus::Uncacheable; + sub.callback_(std::move(result)); + }); +} + +void CacheSession::onCacheError() { + mu_.AssertHeld(); + auto cache_sessions = cache_sessions_.lock(); + if (cache_sessions) { + Event::Dispatcher* dispatcher = nullptr; + if (!lookup_subscribers_.empty()) { + dispatcher = &lookup_subscribers_.front().dispatcher(); + } else if (!body_subscribers_.empty()) { + dispatcher = &body_subscribers_.front().dispatcher(); + } else if (!trailer_subscribers_.empty()) { + dispatcher = &trailer_subscribers_.front().dispatcher(); + } + if (dispatcher) { + // TODO(toddmgreer): there may be some kinds of cache error that + // don't merit evicting the entry. + cache_sessions->cache().evict(*dispatcher, key_); + } + cache_sessions->stats().subCacheSessionsSubscribers(body_subscribers_.size()); + cache_sessions->stats().subCacheSessionsSubscribers(trailer_subscribers_.size()); + cache_sessions->stats().subCacheSessionsSubscribers(lookup_subscribers_.size()); + } + for (LookupSubscriber& sub : lookup_subscribers_) { + postUpstreamPassThrough(std::move(sub), CacheEntryStatus::LookupError); + } + for (BodySubscriber& sub : body_subscribers_) { + sub.callback_(nullptr, EndStream::Reset); + } + for (TrailerSubscriber& sub : trailer_subscribers_) { + sub.callback_(nullptr, EndStream::Reset); + } + lookup_subscribers_.clear(); + body_subscribers_.clear(); + trailer_subscribers_.clear(); + state_ = State::New; +} + +void CacheSession::insertComplete() { + mu_.AssertHeld(); + state_ = State::Exists; + entry_.body_length_ = body_length_available_; + if (content_length_header_ == entry_.body_length_) { + return; + } + if (content_length_header_ != 0) { + ENVOY_LOG(error, + "cache insert for {}{} had content-length header {} but actual size {}. Cache has " + "modified the header to match actual size.", + key_.host(), key_.path(), content_length_header_, entry_.body_length_.value()); + } + content_length_header_ = body_length_available_; +} + +void CacheSession::abortBodyOutOfRangeSubscribers() { + mu_.AssertHeld(); + if (!entry_.body_length_.has_value()) { + // Don't know if a request is out of range until the available range is known. + return; + } + // For any subscribers whose requested range has been revealed to be invalid + // (we only get here in the case where content length was specified in the + // headers, but the actual body was shorter, i.e. the upstream response was + // actually invalid), reset their requests. + // Subscribers who asked for body starting at or beyond the end of the + // real size receive null body rather than reset. + EndStream end_stream = endStreamAfterBody(); + auto cache_sessions = cache_sessions_.lock(); + body_subscribers_.erase( + std::remove_if(body_subscribers_.begin(), body_subscribers_.end(), + [this, end_stream, &cache_sessions](BodySubscriber& bs) + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_) { + if (bs.range_.begin() >= body_length_available_) { + if (bs.range_.begin() == body_length_available_) { + auto cb = std::move(bs.callback_); + bs.dispatcher().post([cb = std::move(cb), end_stream]() mutable { + cb(nullptr, end_stream); + }); + } else { + bs.callback_(nullptr, EndStream::Reset); + } + if (cache_sessions) { + cache_sessions->stats().subCacheSessionsSubscribers(1); + } + return true; + } + return false; + }), + body_subscribers_.end()); +} + +void CacheSession::maybeTriggerBodyReadForWaitingSubscriber() { + mu_.AssertHeld(); + ASSERT(entry_.cache_reader_); + if (read_action_in_flight_) { + // There is already an action in flight so don't read more body yet. + return; + } + abortBodyOutOfRangeSubscribers(); + auto it = std::find_if( + body_subscribers_.begin(), body_subscribers_.end(), + [this](BodySubscriber& subscriber) { return canReadBodyRangeFromCacheEntry(subscriber); }); + if (it == body_subscribers_.end()) { + // There is nobody waiting to read some body that's available. + return; + } + AdjustedByteRange range = it->range_; + if (range.end() > body_length_available_) { + range = AdjustedByteRange(range.begin(), body_length_available_); + } + if (range.length() > max_read_chunk_size_) { + range = AdjustedByteRange(range.begin(), range.begin() + max_read_chunk_size_); + } + // Don't need this to be cancellable because there's a shared_ptr in the lambda keeping the + // CacheSession alive. We post to a thread before making the request for two reasons - we want + // the request to be performed on the requester's worker thread for balance, and we want to be + // able to lock the mutex again on the callback - if the cache called back immediately rather than + // posting and we *didn't* post before making the request, the mutex would still be held + // from this outer function so the callback would deadlock. By posting to a queue we ensure + // that deadlock cannot occur. + // Also, by ensuring the action occurs from a dispatcher queue, we guarantee that + // the "trigger again" at the end of onBodyChunkFromCache can't build up to a stack overflow + // of maybeTrigger->getBody->onBodyChunk->maybeTrigger->... + read_action_in_flight_ = true; + it->dispatcher().post([&dispatcher = it->dispatcher(), p = shared_from_this(), range, + cache_reader = entry_.cache_reader_.get()]() mutable { + cache_reader->getBody( + dispatcher, range, + [p = std::move(p), range](Buffer::InstancePtr buffer, EndStream end_stream) { + p->onBodyChunkFromCache(std::move(range), std::move(buffer), end_stream); + }); + }); +} + +bool CacheSession::canReadBodyRangeFromCacheEntry(BodySubscriber& subscriber) { + mu_.AssertHeld(); + return subscriber.range_.begin() < body_length_available_; +} + +void CacheSession::onBodyChunkFromCache(AdjustedByteRange range, Buffer::InstancePtr buffer, + EndStream end_stream) { + absl::MutexLock lock(&mu_); + read_action_in_flight_ = false; + if (end_stream == EndStream::Reset) { + ENVOY_LOG(error, "cache entry provoked reset"); + onCacheError(); + return; + } + if (buffer == nullptr) { + IS_ENVOY_BUG("cache returned null buffer non-reset"); + onCacheError(); + return; + } + ASSERT(buffer->length() <= range.length()); + if (buffer->length() < range.length()) { + range = AdjustedByteRange(range.begin(), range.begin() + buffer->length()); + } + auto recipients_begin = std::partition(body_subscribers_.begin(), body_subscribers_.end(), + [&range](BodySubscriber& subscriber) { + return subscriber.range_.begin() < range.begin() || + subscriber.range_.begin() >= range.end(); + }); + ASSERT(recipients_begin != body_subscribers_.end(), + "reading body chunk from cache with no corresponding request shouldn't happen"); + if (std::next(recipients_begin) == body_subscribers_.end()) { + BodySubscriber& subscriber = *recipients_begin; + ASSERT(subscriber.range_.begin() == range.begin(), + "if there's only one matching subscriber it should have requested this precise chunk"); + // There is only one recipient of this chunk, send it the actual buffer, + // no need to copy. + sendBodyChunkTo(subscriber, + AdjustedByteRange(subscriber.range_.begin(), + std::min(subscriber.range_.end(), range.end())), + std::move(buffer)); + } else { + uint8_t* bytes = static_cast(buffer->linearize(range.length())); + for (auto it = recipients_begin; it != body_subscribers_.end(); it++) { + AdjustedByteRange r(it->range_.begin(), std::min(it->range_.end(), range.end())); + sendBodyChunkTo( + *it, r, + std::make_unique(bytes + r.begin() - range.begin(), r.length())); + } + } + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().subCacheSessionsSubscribers( + std::distance(recipients_begin, body_subscribers_.end())); + } + body_subscribers_.erase(recipients_begin, body_subscribers_.end()); + maybeTriggerBodyReadForWaitingSubscriber(); +} + +void CacheSession::sendBodyChunkTo(BodySubscriber& subscriber, AdjustedByteRange range, + Buffer::InstancePtr buffer) { + mu_.AssertHeld(); + bool end_stream = entry_.body_length_.has_value() && range.end() == entry_.body_length_.value() && + entry_.response_trailers_ == nullptr; + subscriber.dispatcher().post([end_stream, callback = std::move(subscriber.callback_), + buffer = std::move(buffer)]() mutable { + callback(std::move(buffer), end_stream ? EndStream::End : EndStream::More); + }); +} + +CacheSession::~CacheSession() { ASSERT(!upstream_request_); } + +void CacheSession::getLookupResult(ActiveLookupRequestPtr lookup, ActiveLookupResultCallback&& cb) { + ASSERT(lookup->dispatcher().isThreadSafe()); + absl::MutexLock lock(&mu_); + LookupSubscriber sub{std::make_unique(std::move(lookup), shared_from_this(), + content_length_header_), + std::move(cb)}; + switch (state_) { + case State::Vary: + IS_ENVOY_BUG("not implemented yet"); + ABSL_FALLTHROUGH_INTENDED; + case State::NotCacheable: { + postUpstreamPassThroughWithReset(std::move(sub), shared_from_this()); + return; + } + case State::Validating: + case State::Pending: + sub.context_->lookup().stats().incCacheSessionsSubscribers(); + lookup_subscribers_.push_back(std::move(sub)); + return; + case State::Exists: + case State::Inserting: { + CacheEntryStatus status = CacheEntryStatus::Hit; + if (requiresValidationFor(sub.context_->lookup())) { + if (sub.context_->lookup().requestHeaders().getMethodValue() == + Http::Headers::get().MethodValues.Head) { + // A HEAD request that requires validation can't write to the + // cache or use the cache entry, so just turn it into a pass-through. + return postUpstreamPassThrough(std::move(sub), CacheEntryStatus::Uncacheable); + } + if (state_ == State::Inserting) { + // Skip validation if the cache write is still in progress. + status = CacheEntryStatus::ValidatedFree; + } else { + sub.context_->lookup().stats().incCacheSessionsSubscribers(); + lookup_subscribers_.push_back(std::move(sub)); + return performValidation(); + } + } + auto result = std::make_unique(); + Event::Dispatcher& dispatcher = sub.dispatcher(); + result->http_source_ = std::move(sub.context_); + result->status_ = status; + dispatcher.post([cb = std::move(sub.callback_), result = std::move(result)]() mutable { + cb(std::move(result)); + }); + return; + } + case State::New: { + Event::Dispatcher& dispatcher = sub.dispatcher(); + if (sub.context_->lookup().requestHeaders().getMethodValue() == + Http::Headers::get().MethodValues.Head) { + // HEAD requests are not cacheable, just pass through. + postUpstreamPassThrough(std::move(sub), CacheEntryStatus::Uncacheable); + return; + } + LookupRequest request(Key{sub.context_->lookup().key()}, dispatcher); + sub.context_->lookup().stats().incCacheSessionsSubscribers(); + lookup_subscribers_.emplace_back(std::move(sub)); + state_ = State::Pending; + std::shared_ptr cache_sessions = cache_sessions_.lock(); + ASSERT(cache_sessions, "should be impossible for cache to be deleted in getLookupResult"); + // posted to prevent callback mutex-deadlock. + return dispatcher.post([cache_sessions = std::move(cache_sessions), p = shared_from_this(), + request = std::move(request)]() mutable { + // p is captured as shared_ptr to ensure 'this' is not deleted while the + // lookup is in flight. + cache_sessions->cache().lookup( + std::move(request), [p = std::move(p)](absl::StatusOr&& lookup_result) { + p->onCacheLookupResult(std::move(lookup_result)); + }); + }); + } + } +} + +void CacheSession::onCacheLookupResult(absl::StatusOr&& lookup_result) { + absl::MutexLock lock(&mu_); + if (!lookup_result.ok()) { + return onCacheError(); + } + entry_ = std::move(lookup_result.value()); + if (!entry_.populated()) { + performUpstreamRequest(); + } else { + state_ = State::Exists; + body_length_available_ = entry_.body_length_.value(); + sendLookupResponsesAndMaybeValidationRequest(); + } +} + +void CacheSession::performUpstreamRequest() { + ENVOY_LOG(debug, "making upstream request to populate cache for {}", key_.path()); + mu_.AssertHeld(); + ASSERT(state_ == State::Pending); + ASSERT( + !lookup_subscribers_.empty(), + "upstream request should only be possible if someone requested a lookup and it was a miss"); + ASSERT(!upstream_request_, "should only be one upstream request in flight"); + LookupSubscriber& first_sub = lookup_subscribers_.front(); + const ActiveLookupRequest& lookup = first_sub.context_->lookup(); + Http::RequestHeaderMapPtr request_headers; + bool was_ranged_request = lookup.isRangeRequest(); + if (was_ranged_request) { + request_headers = requestHeadersWithRangeRemoved(lookup.requestHeaders()); + } else { + request_headers = Http::createHeaderMap(lookup.requestHeaders()); + } + upstream_request_ = lookup.createUpstreamRequest(); + first_sub.dispatcher().post([upstream_request = upstream_request_.get(), + request_headers = std::move(request_headers), this, + p = shared_from_this(), was_ranged_request]() mutable { + upstream_request->sendHeaders(std::move(request_headers)); + upstream_request->getHeaders([this, p = std::move(p), was_ranged_request]( + Http::ResponseHeaderMapPtr headers, EndStream end_stream) { + onUpstreamHeaders(std::move(headers), end_stream, was_ranged_request); + }); + }); +} + +void CacheSession::onCacheWentAway() { + mu_.AssertHeld(); + for (LookupSubscriber& sub : lookup_subscribers_) { + postUpstreamPassThrough(std::move(sub), CacheEntryStatus::LookupError); + } + lookup_subscribers_.clear(); +} + +void CacheSession::processSuccessfulValidation(Http::ResponseHeaderMapPtr headers) { + mu_.AssertHeld(); + ENVOY_LOG(debug, "successful validation"); + ASSERT(!lookup_subscribers_.empty(), + "should be impossible to be validating with no context awaiting validation"); + + const bool should_update_cached_entry = + CacheHeadersUtils::shouldUpdateCachedEntry(*headers, *entry_.response_headers_); + // Replace the 304 status code with the cached status code. + headers->setStatus(entry_.response_headers_->getStatusValue()); + + // Remove content length header if the 304 had one; if the cache entry had a + // content length header it will be added by the header adding block below. + headers->removeContentLength(); + + // A response that has been validated should not contain an Age header as it is equivalent to a + // freshly served response from the origin, unless the 304 response has an Age header, which + // means it was served by an upstream cache. + // Remove any existing Age header in the cached response. + entry_.response_headers_->removeInline(CacheCustomHeaders::age()); + + // Add any missing headers from the cached response to the 304 response. + entry_.response_headers_->iterate([&headers](const Http::HeaderEntry& cached_header) { + // TODO(yosrym93): see if we do this without copying the header key twice. + Http::LowerCaseString key(cached_header.key().getStringView()); + if (headers->get(key).empty()) { + headers->setCopy(key, cached_header.value().getStringView()); + } + return Http::HeaderMap::Iterate::Continue; + }); + + entry_.response_headers_ = std::move(headers); + state_ = State::Exists; + if (auto cache_sessions = cache_sessions_.lock()) { + if (should_update_cached_entry) { + // TODO(yosrym93): else evict, set state to Pending, and treat as insert. + LookupSubscriber& sub = lookup_subscribers_.front(); + // Update metadata associated with the cached response. Right now this is only + // response_time. + entry_.response_metadata_.response_time_ = cache_sessions->time_source_.systemTime(); + cache_sessions->cache().updateHeaders(sub.dispatcher(), key_, *entry_.response_headers_, + entry_.response_metadata_); + } + } + + CacheEntryStatus status = CacheEntryStatus::Validated; + for (LookupSubscriber& recipient : lookup_subscribers_) { + sendSuccessfulLookupResultTo(recipient, status); + // For requests sharing the same validation upstream, use a distinct status + // so it's detectable that we didn't need to do multiple validations. + status = CacheEntryStatus::ValidatedFree; + } + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().subCacheSessionsSubscribers(lookup_subscribers_.size()); + } + lookup_subscribers_.clear(); +} + +void CacheSession::onUncacheable(Http::ResponseHeaderMapPtr headers, EndStream end_stream, + bool range_header_was_stripped) { + // If it turned out to be not cacheable, mark it as such, pass the already + // open connection to the first request, and give any other requests in flight + // a pass-through to upstream. + // If the upstream request stripped off a range header from the downstream + // request in order to populate the cache, we'll have to drop that upstream + // request and just issue a new request for every downstream. + mu_.AssertHeld(); + state_ = State::NotCacheable; + bool use_existing_stream = !range_header_was_stripped; + if (!use_existing_stream) { + // Reset the upstream request if the request wanted a range and + // the upstream request didn't want a range. + upstream_request_ = nullptr; + } + for (LookupSubscriber& sub : lookup_subscribers_) { + sub.context_->setContentLength(content_length_header_); + if (use_existing_stream) { + ActiveLookupResultPtr result = std::make_unique(); + result->status_ = CacheEntryStatus::Uncacheable; + result->http_source_ = std::make_unique( + std::move(upstream_request_), std::move(headers), end_stream); + sub.dispatcher().post([result = std::move(result), cb = std::move(sub.callback_)]() mutable { + cb(std::move(result)); + }); + use_existing_stream = false; + } else { + postUpstreamPassThrough(std::move(sub), CacheEntryStatus::Uncacheable); + } + } + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().subCacheSessionsSubscribers(lookup_subscribers_.size()); + } + lookup_subscribers_.clear(); + return; +} + +void CacheSession::onUpstreamHeaders(Http::ResponseHeaderMapPtr headers, EndStream end_stream, + bool range_header_was_stripped) { + absl::MutexLock lock(&mu_); + Event::Dispatcher& dispatcher = lookup_subscribers_.front().dispatcher(); + ASSERT(upstream_request_); + if (end_stream == EndStream::Reset) { + upstream_request_ = nullptr; + state_ = State::New; + for (LookupSubscriber& subscriber : lookup_subscribers_) { + subscriber.dispatcher().post([callback = std::move(subscriber.callback_)]() mutable { + auto result = std::make_unique(); + result->status_ = CacheEntryStatus::UpstreamReset; + callback(std::move(result)); + }); + } + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->stats().subCacheSessionsSubscribers(lookup_subscribers_.size()); + } + lookup_subscribers_.clear(); + return; + } + ASSERT(headers); + if (state_ == State::Validating) { + if (Http::Utility::getResponseStatus(*headers) == enumToInt(Http::Code::NotModified)) { + upstream_request_ = nullptr; + return processSuccessfulValidation(std::move(headers)); + } else { + // Validate failed, so going down the 'insert' path instead. + state_ = State::Pending; + if (auto cache_sessions = cache_sessions_.lock()) { + cache_sessions->cache().evict(dispatcher, key_); + } + body_length_available_ = 0; + entry_ = {}; + } + } else { + ASSERT(state_ == State::Pending, "should only get upstreamHeaders for Validating or Pending"); + } + absl::string_view cl = headers->getContentLengthValue(); + if (!cl.empty()) { + absl::SimpleAtoi(cl, &content_length_header_) || (content_length_header_ = 0); + } + if (!lookup_subscribers_.front().context_->lookup().isCacheableResponse(*headers)) { + return onUncacheable(std::move(headers), end_stream, range_header_was_stripped); + } + if (VaryHeaderUtils::hasVary(*headers)) { + // TODO(ravenblack): implement Vary header support. + ENVOY_LOG(debug, "Vary header found in upstream response, treating as not cacheable"); + return onUncacheable(std::move(headers), end_stream, range_header_was_stripped); + } + auto cache_sessions = cache_sessions_.lock(); + if (!cache_sessions) { + // Cache was deleted while callback was in flight. As a fallback just make all + // requests pass through. This shouldn't happen, but it's possible that a config + // update can come in *and* the last filter using the cache can get + // downstream-disconnected and so deleted, leaving the upstream request + // dangling with no cache to talk to. + ENVOY_LOG(error, "cache config was deleted while upstream request was in flight"); + return onCacheWentAway(); + } + if (end_stream == EndStream::End) { + upstream_request_ = nullptr; + } + // We're already on this subscriber's thread; this is posted to ensure no + // deadlock on the mutex if the insert operation calls back directly. + lookup_subscribers_.front().dispatcher().post( + [p = shared_from_this(), &dispatcher = lookup_subscribers_.front().dispatcher(), key = key_, + cache_sessions, headers = std::move(headers), + upstream_request = std::move(upstream_request_)]() mutable { + cache_sessions->cache().insert(dispatcher, key, std::move(headers), + cache_sessions->makeMetadata(), std::move(upstream_request), + p); + // When the cache entry insertion completes it will call back to onHeadersInserted, + // or on error onInsertFailed. + }); +} + +void CacheSessionsImpl::lookup(ActiveLookupRequestPtr request, ActiveLookupResultCallback&& cb) { + ASSERT(request); + ASSERT(cb); + std::shared_ptr entry = getEntry(request->key()); + entry->getLookupResult(std::move(request), std::move(cb)); +} + +ResponseMetadata CacheSessionsImpl::makeMetadata() { + ResponseMetadata metadata; + metadata.response_time_ = time_source_.systemTime(); + return metadata; +} + +void CacheSession::performValidation() { + mu_.AssertHeld(); + ASSERT(!lookup_subscribers_.empty()); + ENVOY_LOG(debug, "validating"); + state_ = State::Validating; + LookupSubscriber& first_sub = lookup_subscribers_.front(); + const ActiveLookupRequest& lookup = first_sub.context_->lookup(); + Http::RequestHeaderMapPtr req = requestHeadersWithRangeRemoved(lookup.requestHeaders()); + CacheHeadersUtils::injectValidationHeaders(*req, *entry_.response_headers_); + upstream_request_ = lookup.createUpstreamRequest(); + first_sub.dispatcher().post([upstream_request = upstream_request_.get(), req = std::move(req), + this, p = shared_from_this()]() mutable { + upstream_request->sendHeaders(std::move(req)); + upstream_request->getHeaders( + [this, p = std::move(p)](Http::ResponseHeaderMapPtr headers, EndStream end_stream) { + onUpstreamHeaders(std::move(headers), end_stream, false); + }); + }); +} + +std::shared_ptr CacheSessionsImpl::getEntry(const Key& key) { + const SystemTime now = time_source_.systemTime(); + cache().touch(key, now); + absl::MutexLock lock(&mu_); + auto [it, is_new] = entries_.try_emplace(key); + if (is_new) { + stats().incCacheSessionsEntries(); + it->second = std::make_shared(weak_from_this(), key); + } + auto ret = it->second; + ret->setExpiry(now + expiry_duration_); + // As a lazy way of keeping the cache metadata from growing endlessly, + // remove at most one adjacent metadata entry every time an entry is touched + // if the adjacent entry hasn't been touched in a while. + // This should do a decent job of expiring them simply, with a low cost, and + // without taking any long-lived locks as would be required for periodic + // scanning. + if (++it == entries_.end()) { + it = entries_.begin(); + } + if (it->second->isExpiredAt(now)) { + stats().decCacheSessionsEntries(); + entries_.erase(it); + } + return ret; +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cache_sessions_impl.h b/source/extensions/filters/http/cache_v2/cache_sessions_impl.h new file mode 100644 index 0000000000000..35f9b749b149a --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cache_sessions_impl.h @@ -0,0 +1,310 @@ +#pragma once + +#include "envoy/buffer/buffer.h" + +#include "source/common/common/cancel_wrapper.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/filters/http/cache_v2/upstream_request.h" + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" +#include "absl/synchronization/mutex.h" +#include "stats.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class CacheSession; +class CacheSessionsImpl; + +class ActiveLookupContext : public HttpSource { +public: + ActiveLookupContext(ActiveLookupRequestPtr lookup, std::shared_ptr entry, + uint64_t content_length = 0) + : lookup_(std::move(lookup)), entry_(entry), content_length_(content_length) {} + // HttpSource + void getHeaders(GetHeadersCallback&& cb) override; + void getBody(AdjustedByteRange range, GetBodyCallback&& cb) override; + void getTrailers(GetTrailersCallback&& cb) override; + + Event::Dispatcher& dispatcher() const { return lookup().dispatcher(); } + ActiveLookupRequest& lookup() const { return *lookup_; } + + void setContentLength(uint64_t l) { content_length_ = l; } + +private: + ActiveLookupRequestPtr lookup_; + std::shared_ptr entry_; + uint64_t content_length_; +}; + +class CacheSession : public Logger::Loggable, + public CacheProgressReceiver, + public std::enable_shared_from_this { +public: + CacheSession(std::weak_ptr cache_sessions, const Key& key); + + // CacheProgressReceiver + void onHeadersInserted(CacheReaderPtr cache_reader, Http::ResponseHeaderMapPtr headers, + bool end_stream) override; + void onBodyInserted(AdjustedByteRange range, bool end_stream) override; + void onTrailersInserted(Http::ResponseTrailerMapPtr trailers) override; + void onInsertFailed(absl::Status status) override; + + void getLookupResult(ActiveLookupRequestPtr lookup, + ActiveLookupResultCallback&& lookup_result_callback) + ABSL_LOCKS_EXCLUDED(mu_); + void onCacheLookupResult(absl::StatusOr&& result) ABSL_LOCKS_EXCLUDED(mu_); + + void wantHeaders(Event::Dispatcher& dispatcher, SystemTime lookup_timestamp, + GetHeadersCallback&& cb) ABSL_LOCKS_EXCLUDED(mu_); + void wantBodyRange(AdjustedByteRange range, Event::Dispatcher& dispatcher, GetBodyCallback&& cb) + ABSL_LOCKS_EXCLUDED(mu_); + void wantTrailers(Event::Dispatcher& dispatcher, GetTrailersCallback&& cb) + ABSL_LOCKS_EXCLUDED(mu_); + void clearUncacheableState() ABSL_LOCKS_EXCLUDED(mu_); + + ~CacheSession(); + + class Subscriber { + public: + explicit Subscriber(Event::Dispatcher& dispatcher) : dispatcher_(dispatcher) {} + Event::Dispatcher& dispatcher() { return dispatcher_.get(); } + + private: + // In order to be moveable in a vector we can't use a plain reference. + std::reference_wrapper dispatcher_; + }; + class BodySubscriber : public Subscriber { + public: + BodySubscriber(Event::Dispatcher& dispatcher, AdjustedByteRange range, GetBodyCallback&& cb) + : Subscriber(dispatcher), callback_(std::move(cb)), range_(std::move(range)) {} + GetBodyCallback callback_; + AdjustedByteRange range_; + }; + class TrailerSubscriber : public Subscriber { + public: + TrailerSubscriber(Event::Dispatcher& dispatcher, GetTrailersCallback&& cb) + : Subscriber(dispatcher), callback_(std::move(cb)) {} + GetTrailersCallback callback_; + }; + class LookupSubscriber : public Subscriber { + public: + LookupSubscriber(std::unique_ptr context, ActiveLookupResultCallback&& cb) + : Subscriber(context->dispatcher()), callback_(std::move(cb)), + context_(std::move(context)) {} + ActiveLookupResultCallback callback_; + std::unique_ptr context_; + }; + +private: + enum class State { + // New state means this is the first client of the cache entry - it should immediately + // update the state to Pending and attempt a lookup (then if necessary insertion). + New, + // Pending state means another client is already doing lookup/insertion/verification. + // Client should subscribe to this, and act on received messages. + Pending, + // Inserting state means a cache entry exists but has not yet completed writing. + Inserting, + // Exists state means a cache entry probably exists. Client should attempt to read from + // the entry. On cache failure, state should revert to New. On expiry, state should become + // Validating. + Exists, + // Validating state means the cache entry exists but either is expired or some header has + // explicitly required validation from upstream. + Validating, + // Vary state means the cache entry includes headers and the request must be + // re-keyed onto the appropriate variation key. + Vary, + // NotCacheable state means this key is considered non-cacheable. Client should pass through. + // If the passed-through response turns out to be cacheable (i.e. upstream has changed + // cache headers), client should update state to Writing, or, if state is already changed, + // client should abort the new upstream request and use the shared one. + NotCacheable + }; + + EndStream endStreamAfterHeaders() const ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + EndStream endStreamAfterBody() const ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // Switches state to Written, removes the insert_context_, notifies all + // subscribers. + void insertComplete() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // Switches state to New, removes the insert_context_, resets all subscribers. + // Ideally this shouldn't happen, but an unreliable upstream could cause it. + // TODO(ravenblackx): this could theoretically be improved with a retry process + // rather than resetting all the downstreams on error, but that's beyond MVP. + void insertAbort() ABSL_LOCKS_EXCLUDED(mu_); + + void headersWritten(const Http::ResponseHeaderMap&& response_headers, + ResponseMetadata&& response_metadata, + absl::optional content_length_override, bool end_stream) + ABSL_LOCKS_EXCLUDED(mu_); + + // Populates the headers in memory. + void saveHeaders(const Http::ResponseHeaderMap&& response_headers, + ResponseMetadata&& response_metadata, absl::optional content_length, + bool end_stream) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + bool requiresValidationFor(const ActiveLookupRequest& lookup) const + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // For each subscriber, either sends a lookup response (if validation passes), or + // triggers validation *once* for all subscribers for whom validation failed. + // If an insert occurred then first_status should be Miss, otherwise Hit. + void sendLookupResponsesAndMaybeValidationRequest( + CacheEntryStatus first_status = CacheEntryStatus::Hit) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // Sends an upstream validation request. + void performValidation() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void processSuccessfulValidation(Http::ResponseHeaderMapPtr headers) + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // If the headers include vary, update all blocked subscribers with their new keys + // and returns true. Otherwise returns false. + bool handleVary(const Http::ResponseHeaderMap&& response_headers) + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // Called by the InsertContext. + // Updates the state to reflect the increased availability, and + // triggers a file-read action if there is a subscriber waiting on a body chunk + // within the available range, and no read file action is in flight. + void bodyWrittenTo(uint64_t sz, bool end_stream) ABSL_LOCKS_EXCLUDED(mu_); + + // Called by the InsertContext. + // Populates the trailers in memory, and calls sendTrailers. + void trailersWritten(Http::ResponseTrailerMapPtr response_trailers) ABSL_LOCKS_EXCLUDED(mu_); + + // Attempts to open the cache file. + // + // On failure notifies the first queued LookupContext of a cache miss, so + // the cache entry can be either populated or marked as uncacheable. + // + // On success, attempts to validate the cache entry. + // + // If it is valid, all queued LookupContexts are notified to use the file. + // + // If it is not valid, attempts to populate the cache entry. + // + // If attempt to populate the cache entry fails, marks as uncacheable, + // hands the UpstreamRequest to the first LookupContext, and notifies the + // rest of the queue that the result is uncacheable and they should bypass + // the cache, or, if the original request had a range header which was + // discarded for the UpstreamRequest, the UpstreamRequest is reset and *all* + // LookupContexts are notified to bypass the cache. + void sendSuccessfulLookupResultTo(LookupSubscriber& subscriber, CacheEntryStatus status) + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void checkCacheEntryExistence(Event::Dispatcher& dispatcher) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void onCacheEntryExistence(LookupResult&& lookup_result) ABSL_LOCKS_EXCLUDED(mu_); + void sendBodyChunkTo(BodySubscriber& subscriber, AdjustedByteRange range, Buffer::InstancePtr buf) + ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void sendTrailersTo(TrailerSubscriber& subscriber) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void sendAbortTo(Subscriber& subscriber) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + bool tryEnqueueBodyChunk(BodySubscriber& subscriber) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + // If there's not already a read operation in flight and any requested + // range is within the available range, start an operation to + // read that range (prioritized by oldest subscriber). + void maybeTriggerBodyReadForWaitingSubscriber() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + bool selectBodyToRead() ABSL_LOCKS_EXCLUDED(mu_); + void abortBodyOutOfRangeSubscribers() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + bool canReadBodyRangeFromCacheEntry(BodySubscriber& subscriber); + void onBodyChunkFromCache(AdjustedByteRange range, Buffer::InstancePtr buffer, + EndStream end_stream) ABSL_LOCKS_EXCLUDED(mu_); + void onCacheError() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + void doCacheEntryInvalid() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void doCacheMiss() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void validateCacheEntry(Event::Dispatcher& dispatcher) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void performUpstreamRequest() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + void onUpstreamHeaders(Http::ResponseHeaderMapPtr headers, EndStream end_stream, + bool range_header_was_stripped) ABSL_LOCKS_EXCLUDED(mu_); + void onUncacheable(Http::ResponseHeaderMapPtr headers, EndStream end_stream, + bool range_header_was_stripped) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + // For the unlikely case that cache config was modified while operations were in flight, + // requests still in the lookup state are transformed to pass-through. + // Requests for headers/body/trailers should be able to continue as the cache + // *entries* can outlive the cache object itself as long as they're in use. + void onCacheWentAway() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + // May change state from New to Pending, or from Written to Validating. + // When changing state, also makes the corresponding upstream request. + void mutateStateForHeaderRequest(const LookupRequest& lookup) ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + bool headersAreReady() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_); + + mutable absl::Mutex mu_; + State state_ ABSL_GUARDED_BY(mu_) = State::New; + uint64_t content_length_header_ = 0; + LookupResult entry_ ABSL_GUARDED_BY(mu_); + // While streaming this is a proxy for body_length_ which should not + // be populated in entry_ until the insert is complete. + uint64_t body_length_available_ = 0; + std::weak_ptr cache_sessions_; + Key key_; + bool in_body_loop_callback_ = false; + + std::vector lookup_subscribers_ ABSL_GUARDED_BY(mu_); + std::vector body_subscribers_ ABSL_GUARDED_BY(mu_); + std::vector trailer_subscribers_ ABSL_GUARDED_BY(mu_); + UpstreamRequestPtr upstream_request_ ABSL_GUARDED_BY(mu_); + bool read_action_in_flight_ ABSL_GUARDED_BY(mu_) = false; + + // The following fields and functions are only used by CacheSessions. + friend class CacheSessionsImpl; + bool inserting() const { + absl::MutexLock lock(&mu_); + return state_ == State::Inserting; + } + void setExpiry(SystemTime expiry) { expires_at_ = expiry; } + bool isExpiredAt(SystemTime t) const { return expires_at_ < t && !inserting(); } + + SystemTime expires_at_; // This is guarded by CacheSessions's mutex. + + // An arbitrary 256k limit on per-read fragment size. + // TODO(ravenblack): Make this configurable? + static constexpr uint64_t max_read_chunk_size_ = 256 * 1024; +}; + +class CacheSessionsImpl : public CacheSessions, + public std::enable_shared_from_this { +public: + CacheSessionsImpl(Server::Configuration::FactoryContext& context, + std::unique_ptr cache) + : time_source_(context.serverFactoryContext().timeSource()), cache_(std::move(cache)), + stats_(generateStats(context.scope(), cache_->cacheInfo().name_)) {} + + void lookup(ActiveLookupRequestPtr request, ActiveLookupResultCallback&& cb) override; + CacheFilterStats& stats() const override { return *stats_; } + + ResponseMetadata makeMetadata(); + + HttpCache& cache() const override { return *cache_; } + +private: + // Returns an entry with the given key, creating it if necessary. + std::shared_ptr getEntry(const Key& key) ABSL_LOCKS_EXCLUDED(mu_); + + TimeSource& time_source_; + std::unique_ptr cache_; + CacheFilterStatsPtr stats_; + std::chrono::duration expiry_duration_ = std::chrono::minutes(5); + mutable absl::Mutex mu_; + // If there turns out to be problematic contention on this mutex, this could + // easily be turned into a simple short-hash-keyed array of maps each with + // their own mutex. Since it's only held for a short time and is related to + // async operations, it seems unlikely that mutex contention would be a + // significant bottleneck. + absl::flat_hash_map, MessageUtil, MessageUtil> + entries_ ABSL_GUARDED_BY(mu_); + + friend class CacheSession; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cacheability_utils.cc b/source/extensions/filters/http/cache_v2/cacheability_utils.cc new file mode 100644 index 0000000000000..a237fba0b5f59 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cacheability_utils.cc @@ -0,0 +1,99 @@ +#include "source/extensions/filters/http/cache_v2/cacheability_utils.h" + +#include "envoy/http/header_map.h" + +#include "source/common/common/macros.h" +#include "source/common/common/utility.h" +#include "source/common/http/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +namespace { +const absl::flat_hash_set& cacheableStatusCodes() { + // As defined by: + // https://tools.ietf.org/html/rfc7231#section-6.1, + // https://tools.ietf.org/html/rfc7538#section-3, + // https://tools.ietf.org/html/rfc7725#section-3 + // TODO(yosrym93): the list of cacheable status codes should be configurable. + CONSTRUCT_ON_FIRST_USE(absl::flat_hash_set, "200", "203", "204", "206", "300", + "301", "308", "404", "405", "410", "414", "451", "501"); +} + +const std::vector& conditionalHeaders() { + // As defined by: https://httpwg.org/specs/rfc7232.html#preconditions. + CONSTRUCT_ON_FIRST_USE( + std::vector, &Http::CustomHeaders::get().IfNoneMatch, + &Http::CustomHeaders::get().IfModifiedSince, &Http::CustomHeaders::get().IfRange); +} +} // namespace + +absl::Status CacheabilityUtils::canServeRequestFromCache(const Http::RequestHeaderMap& headers) { + const absl::string_view method = headers.getMethodValue(); + const Http::HeaderValues& header_values = Http::Headers::get(); + + // Check if the request contains any conditional headers other than if-unmodified-since + // or if-match. + // For now, requests with conditional headers bypass the CacheFilter. + // This behavior does not cause any incorrect results, but may reduce the cache effectiveness. + // If needed to be handled properly refer to: + // https://httpwg.org/specs/rfc7234.html#validation.received + // if-unmodified-since and if-match are ignored, as the spec explicitly says these + // header fields can be ignored by caches and intermediaries. + for (auto conditional_header : conditionalHeaders()) { + if (!headers.get(*conditional_header).empty()) { + return absl::InvalidArgumentError(*conditional_header); + } + } + + // TODO(toddmgreer): Also serve HEAD requests from cache. + // Cache-related headers are checked in HttpCache::LookupRequest. + if (!headers.Path()) { + return absl::InvalidArgumentError("no path"); + } + if (!headers.Host()) { + return absl::InvalidArgumentError("no host"); + } + if (headers.getInline(CacheCustomHeaders::authorization())) { + return absl::InvalidArgumentError("authorization"); + } + if (method.empty()) { + return absl::InvalidArgumentError("no method"); + } + if (method != header_values.MethodValues.Get && method != header_values.MethodValues.Head) { + return absl::InvalidArgumentError(method); + } + if (!Http::Utility::schemeIsValid(headers.getSchemeValue())) { + return absl::InvalidArgumentError("scheme"); + } + return absl::OkStatus(); +} + +bool CacheabilityUtils::isCacheableResponse(const Http::ResponseHeaderMap& headers, + const VaryAllowList& vary_allow_list) { + absl::string_view cache_control = + headers.getInlineValue(CacheCustomHeaders::responseCacheControl()); + ResponseCacheControl response_cache_control(cache_control); + + // Only cache responses with enough data to calculate freshness lifetime as per: + // https://httpwg.org/specs/rfc7234.html#calculating.freshness.lifetime. + // Either: + // "no-cache" cache-control directive (requires revalidation anyway). + // "max-age" or "s-maxage" cache-control directives. + // Both "Expires" and "Date" headers. + const bool has_validation_data = + response_cache_control.must_validate_ || response_cache_control.max_age_.has_value() || + (headers.Date() && headers.getInline(CacheCustomHeaders::expires())); + + return !response_cache_control.no_store_ && + cacheableStatusCodes().contains((headers.getStatusValue())) && has_validation_data && + vary_allow_list.allowsHeaders(headers); +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/cacheability_utils.h b/source/extensions/filters/http/cache_v2/cacheability_utils.h new file mode 100644 index 0000000000000..2e54b58f2404b --- /dev/null +++ b/source/extensions/filters/http/cache_v2/cacheability_utils.h @@ -0,0 +1,33 @@ +#pragma once + +#include "source/common/common/utility.h" +#include "source/common/http/headers.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +#include "absl/status/status.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace CacheabilityUtils { +// Checks if a request can be served from cache. +// This does not depend on cache-control headers as +// request cache-control headers only decide whether +// validation is required and whether the response can be cached. +absl::Status canServeRequestFromCache(const Http::RequestHeaderMap& headers); + +// Checks if a response can be stored in cache. +// Note that if a request is not cacheable according to 'canServeRequestFromCache' +// then its response is also not cacheable. +// Therefore, canServeRequestFromCache, isCacheableResponse and +// CacheFilter::request_allows_inserts_ together should cover +// https://httpwg.org/specs/rfc7234.html#response.cacheability. Head requests are not +// cacheable. However, this function is never called for head requests. +bool isCacheableResponse(const Http::ResponseHeaderMap& headers, + const VaryAllowList& vary_allow_list); +} // namespace CacheabilityUtils +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/config.cc b/source/extensions/filters/http/cache_v2/config.cc new file mode 100644 index 0000000000000..6c7581c7acee3 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/config.cc @@ -0,0 +1,47 @@ +#include "source/extensions/filters/http/cache_v2/config.h" + +#include "source/extensions/filters/http/cache_v2/cache_filter.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/filters/http/cache_v2/stats.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +Http::FilterFactoryCb CacheFilterFactory::createFilterFactoryFromProtoTyped( + const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& config, + const std::string& /*stats_prefix*/, Server::Configuration::FactoryContext& context) { + std::shared_ptr cache; + if (!config.disabled().value()) { + if (!config.has_typed_config()) { + throw EnvoyException("at least one of typed_config or disabled must be set"); + } + const std::string type{TypeUtil::typeUrlToDescriptorFullName(config.typed_config().type_url())}; + HttpCacheFactory* const http_cache_factory = + Registry::FactoryRegistry::getFactoryByType(type); + if (http_cache_factory == nullptr) { + throw EnvoyException( + fmt::format("Didn't find a registered implementation for type: '{}'", type)); + } + + absl::StatusOr> status_or_cache = + http_cache_factory->getCache(config, context); + if (!status_or_cache.ok()) { + throw EnvoyException(fmt::format("Couldn't initialize cache: {}", status_or_cache.status())); + } + cache = *std::move(status_or_cache); + } + return + [config = std::make_shared(config, cache, context.serverFactoryContext())]( + Http::FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addStreamFilter(std::make_shared(config)); + }; +} + +REGISTER_FACTORY(CacheFilterFactory, Server::Configuration::NamedHttpFilterConfigFactory); + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/config.h b/source/extensions/filters/http/cache_v2/config.h new file mode 100644 index 0000000000000..347b15025040a --- /dev/null +++ b/source/extensions/filters/http/cache_v2/config.h @@ -0,0 +1,27 @@ +#pragma once + +#include "envoy/extensions/filters/http/cache_v2/v3/cache.pb.h" +#include "envoy/extensions/filters/http/cache_v2/v3/cache.pb.validate.h" + +#include "source/extensions/filters/http/common/factory_base.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class CacheFilterFactory + : public Common::FactoryBase { +public: + CacheFilterFactory() : FactoryBase("envoy.filters.http.cache_v2") {} + +private: + Http::FilterFactoryCb createFilterFactoryFromProtoTyped( + const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& config, + const std::string& stats_prefix, Server::Configuration::FactoryContext& context) override; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/http_cache.cc b/source/extensions/filters/http/cache_v2/http_cache.cc new file mode 100644 index 0000000000000..70d3505d349bd --- /dev/null +++ b/source/extensions/filters/http/cache_v2/http_cache.cc @@ -0,0 +1,34 @@ +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +#include +#include +#include + +#include "envoy/http/codes.h" +#include "envoy/http/header_map.h" + +#include "source/common/http/header_utility.h" +#include "source/common/http/headers.h" +#include "source/common/http/utility.h" +#include "source/common/protobuf/deterministic_hash.h" +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +#include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +size_t stableHashKey(const Key& key) { return DeterministicProtoHash::hash(key); } + +LookupRequest::LookupRequest(Key&& key, Event::Dispatcher& dispatcher) + : dispatcher_(dispatcher), key_(key) {} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/http_cache.h b/source/extensions/filters/http/cache_v2/http_cache.h new file mode 100644 index 0000000000000..484914bdcea4c --- /dev/null +++ b/source/extensions/filters/http/cache_v2/http_cache.h @@ -0,0 +1,163 @@ +#pragma once + +#include +#include + +#include "envoy/common/time.h" +#include "envoy/config/typed_config.h" +#include "envoy/extensions/filters/http/cache_v2/v3/cache.pb.h" +#include "envoy/http/header_map.h" +#include "envoy/server/factory_context.h" + +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_progress_receiver.h" +#include "source/extensions/filters/http/cache_v2/http_source.h" +#include "source/extensions/filters/http/cache_v2/key.pb.h" +#include "source/extensions/filters/http/cache_v2/range_utils.h" + +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class CacheSessions; +class CacheReader; + +// Result of a lookup operation. +struct LookupResult { + std::unique_ptr cache_reader_; + std::unique_ptr response_headers_; + std::unique_ptr response_trailers_; + ResponseMetadata response_metadata_; + absl::optional body_length_; + bool populated() const { return body_length_.has_value(); } +}; + +// Produces a hash of key that is consistent across restarts, architectures, +// builds, and configurations. Caches that store persistent entries based on a +// 64-bit hash should (but are not required to) use stableHashKey. Once this API +// leaves alpha, any improvements to stableHashKey that would change its output +// for existing callers is a breaking change. +// +// For non-persistent storage, use MessageUtil, which has no long-term stability +// guarantees. +// +// When providing a cached response, Caches must ensure that the keys (and not +// just their hashes) match. +size_t stableHashKey(const Key& key); + +// LookupRequest holds everything about a request that's needed to look for a +// response in a cache, to evaluate whether an entry from a cache is usable, and +// to determine what ranges are needed. +class LookupRequest { +public: + // Prereq: request_headers's Path(), Scheme(), and Host() are non-null. + LookupRequest(Key&& key, Event::Dispatcher& dispatcher); + + // 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_; } + + Event::Dispatcher& dispatcher() const { return dispatcher_; } + +private: + Event::Dispatcher& dispatcher_; + Key key_; +}; + +// Statically known information about a cache. +struct CacheInfo { + absl::string_view name_; +}; + +class CacheReader { +public: + // May call the callback immediately; dispatcher is provided as an option to facilitate + // asynchronous operations. + // Will only be called with ranges the cache has announced are available, either via + // CacheProgressReceiver::onBodyInserted or via HttpCache::LookupCallback. + // end_stream should always be More, unless a cache error occurs in which case Reset - + // client already knows the body length so cache does not need to detect 'End'. + virtual void getBody(Event::Dispatcher& dispatcher, AdjustedByteRange range, + GetBodyCallback&& cb) PURE; + virtual ~CacheReader() = default; +}; +using CacheReaderPtr = std::unique_ptr; + +// Implement this interface to provide a cache implementation for use by +// CacheFilter. +class HttpCache { +public: + // LookupCallback returns an empty LookupResult if the cache entry does not exist. + // Statuses are for actual errors. + using LookupCallback = absl::AnyInvocable&&)>; + + // Returns statically known information about a cache. + virtual CacheInfo cacheInfo() const PURE; + + // Calls the callback with a LookupResult; its body_length_ should be nullopt + // if the key was not found in the cache. Its cache_reader may be nullopt if the + // cache entry has no body. + // Using the dispatcher is optional, the callback is thread-safe. + // The callback must be called - if the cache is deleted while a callback + // is still in flight, the callback should be called with an error status. + virtual void lookup(LookupRequest&& request, LookupCallback&& callback) PURE; + + // Remove the entry from the cache. + // This should accept any dispatcher, as the cache has no worker affinity. + virtual void evict(Event::Dispatcher& dispatcher, const Key& key) PURE; + + // To facilitate LRU cache eviction, provide a timestamp whenever a cache entry is + // looked up. + virtual void touch(const Key& key, SystemTime timestamp) PURE; + + // Replaces the headers in the cache. + // If this requires asynchronous operations, getBody must continue to function for the duration + // (perhaps reading from the existing data). + // This should avoid modifying the data in-place non-atomically, as during hot restart or other + // circumstances in which multiple instances are accessing the same cache, the data store could + // be read from while partially written. + // If the key doesn't exist, this should be a no-op. + virtual void updateHeaders(Event::Dispatcher& dispatcher, const Key& key, + const Http::ResponseHeaderMap& updated_headers, + const ResponseMetadata& updated_metadata) PURE; + + // insert is only called after the headers have been read successfully and confirmed + // to be cacheable, so the headers are provided immediately as the HttpSource has + // already consumed them. + // If end_stream was true, HttpSourcePtr is null. + // The cache insert for future lookup() should only be completed atomically when the + // insertion is finished, while the CacheReader passed to progress->onHeadersInserted + // should be ready for streaming from immediately (subject to relevant body progress). + virtual void insert(Event::Dispatcher& dispatcher, Key key, Http::ResponseHeaderMapPtr headers, + ResponseMetadata metadata, HttpSourcePtr source, + std::shared_ptr progress) PURE; + virtual ~HttpCache() = default; +}; + +// Factory interface for cache implementations to implement and register. +class HttpCacheFactory : public Config::TypedFactory { +public: + // From UntypedFactory + std::string category() const override { return "envoy.http.cache_v2"; } + + // Returns a CacheSessions initialized with an HttpCache that will remain + // valid indefinitely (at least as long as the calling CacheFilter). + // + // Pass factory context to allow HttpCache to use async client, stats scope + // etc. + virtual absl::StatusOr> + getCache(const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& config, + Server::Configuration::FactoryContext& context) PURE; + +private: + const std::string name_; +}; +using HttpCacheFactoryPtr = std::unique_ptr; +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/http_source.h b/source/extensions/filters/http/cache_v2/http_source.h new file mode 100644 index 0000000000000..11a1c081f5347 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/http_source.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include "envoy/buffer/buffer.h" +#include "envoy/http/header_map.h" + +#include "source/extensions/filters/http/cache_v2/range_utils.h" + +#include "absl/functional/any_invocable.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// Reset indicates that the upstream source reset (or, if it's not a stream, some +// kind of unexpected error). +// More is equivalent to bool end_stream=false. +// End is equivalent to bool end_stream=true. +enum class EndStream { Reset, More, End }; +using GetHeadersCallback = + absl::AnyInvocable; +using GetBodyCallback = absl::AnyInvocable; +using GetTrailersCallback = + absl::AnyInvocable; + +// HttpSource is an interface for a source of HTTP data. +// Callbacks can potentially be called before returning from the get* function. +// The callback should be called on the same thread as the caller. +// Only one request should be in flight at a time, and requests must be in +// order as the source is assumed to be a stream (i.e. headers before body, +// earlier body before later body, trailers last). +class HttpSource { +public: + // Calls the provided callback with http headers. + virtual void getHeaders(GetHeadersCallback&& cb) PURE; + // Calls the provided callback with a buffer that is the beginning of the + // requested range, up to but not necessarily including the entire requested + // range, or no buffer if there is no more data or an error occurred. + virtual void getBody(AdjustedByteRange range, GetBodyCallback&& cb) PURE; + virtual void getTrailers(GetTrailersCallback&& cb) PURE; + virtual ~HttpSource() = default; +}; + +using HttpSourcePtr = std::unique_ptr; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/key.proto b/source/extensions/filters/http/cache_v2/key.proto new file mode 100644 index 0000000000000..fc5cc9dc37db4 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/key.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package Envoy.Extensions.HttpFilters.CacheV2; + +// Cache key for lookups and inserts. +message Key { + string cluster_name = 1; + string host = 2; + string path = 3; + string query = 4; + // True for http://, false for https://. + bool clear_http = 5 [deprecated = true]; // Use scheme instead. + enum Scheme { + UNSPECIFIED = 0; + HTTP = 1; + HTTPS = 2; + } + // If UNSPECIFIED, the scheme is not included in the cache key, so http and + // https will map to the same cache entry. Otherwise, the scheme is included + // in the cache key. + Scheme scheme = 8; + // Cache implementations can store arbitrary content in these fields; never set by cache filter. + repeated bytes custom_fields = 6; + repeated int64 custom_ints = 7; +}; diff --git a/source/extensions/filters/http/cache_v2/range_utils.cc b/source/extensions/filters/http/cache_v2/range_utils.cc new file mode 100644 index 0000000000000..efacbed281f1c --- /dev/null +++ b/source/extensions/filters/http/cache_v2/range_utils.cc @@ -0,0 +1,171 @@ +#include "source/extensions/filters/http/cache_v2/range_utils.h" + +#include +#include +#include +#include +#include +#include + +#include "envoy/http/header_map.h" + +#include "source/common/common/assert.h" +#include "source/common/common/logger.h" +#include "source/common/http/headers.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +#include "absl/strings/str_split.h" +#include "absl/strings/string_view.h" +#include "absl/strings/strip.h" +#include "absl/types/optional.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +std::ostream& operator<<(std::ostream& os, const AdjustedByteRange& range) { + return os << "[" << range.begin() << "," << range.end() << ")"; +} + +absl::optional +RangeUtils::createRangeDetails(const Envoy::Http::RequestHeaderMap& request_headers, + uint64_t content_length) { + if (absl::optional range_header = RangeUtils::getRangeHeader(request_headers); + range_header.has_value()) { + return RangeUtils::createRangeDetails(range_header.value(), content_length); + } + return absl::nullopt; +} + +absl::optional RangeUtils::createRangeDetails(const absl::string_view range_header, + const uint64_t content_length) { + // TODO(cbdm): using a constant limit of 1 range since we don't support + // multi-part responses nor coalesce multiple overlapping ranges. Could make + // this into a parameter based on config. + const int RangeSpecifierLimit = 1; + absl::optional> request_range_spec = + RangeUtils::parseRangeHeader(range_header, RangeSpecifierLimit); + if (!request_range_spec.has_value()) { + return absl::nullopt; + } + + return RangeUtils::createAdjustedRangeDetails(request_range_spec.value(), content_length); +} + +absl::optional +RangeUtils::getRangeHeader(const Envoy::Http::RequestHeaderMap& headers) { + const Envoy::Http::HeaderMap::GetResult range_header = + headers.get(Envoy::Http::Headers::get().Range); + if (range_header.size() == 1) { + return range_header[0]->value().getStringView(); + } else { + return absl::nullopt; + } +} + +// TODO(kiehl): Write tests now that this function is stand alone. +RangeDetails +RangeUtils::createAdjustedRangeDetails(const std::vector& request_range_spec, + uint64_t content_length) { + if (request_range_spec.empty()) { + // No range header, so the request can proceed. + return {true, {}}; + } + + if (content_length == 0) { + // There is a range header, but it's unsatisfiable. + return {false, {}}; + } + + RangeDetails result; + for (const RawByteRange& spec : request_range_spec) { + if (spec.isSuffix()) { + // spec is a suffix-byte-range-spec. + if (spec.suffixLength() == 0) { + // This range is unsatisfiable. + return {false, {}}; + } + if (spec.suffixLength() >= content_length) { + // All bytes are being requested, so we may as well send a '200 + // OK' response. + return {true, {}}; + } + result.ranges_.emplace_back(content_length - spec.suffixLength(), content_length); + } else { + // spec is a byte-range-spec + if (spec.firstBytePos() >= content_length) { + // This range is unsatisfiable. + return {false, {}}; + } + if (spec.lastBytePos() >= content_length - 1) { + if (spec.firstBytePos() == 0) { + // All bytes are being requested, so we may as well send a '200 + // OK' response. + return {true, {}}; + } + result.ranges_.emplace_back(spec.firstBytePos(), content_length); + } else { + result.ranges_.emplace_back(spec.firstBytePos(), spec.lastBytePos() + 1); + } + } + } + + result.satisfiable_ = !result.ranges_.empty(); + + return result; +} + +absl::optional> +RangeUtils::parseRangeHeader(absl::string_view range_header, uint64_t max_byte_range_specs) { + if (!absl::ConsumePrefix(&range_header, "bytes=")) { + return absl::nullopt; + } + + std::vector ranges = + absl::StrSplit(range_header, absl::MaxSplits(',', max_byte_range_specs)); + if (ranges.size() > max_byte_range_specs) { + return absl::nullopt; + } + std::vector parsed_ranges; + for (absl::string_view cur_range : ranges) { + absl::optional first = CacheHeadersUtils::readAndRemoveLeadingDigits(cur_range); + + if (!absl::ConsumePrefix(&cur_range, "-")) { + return absl::nullopt; + } + + absl::optional last = CacheHeadersUtils::readAndRemoveLeadingDigits(cur_range); + + if (!cur_range.empty()) { + return absl::nullopt; + } + + if (!first && !last) { + return absl::nullopt; + } + + // Handle suffix range (e.g., -123). + if (!first) { + first = std::numeric_limits::max(); + } + + // Handle optional range-end (e.g., 123-). + if (!last) { + last = std::numeric_limits::max(); + } + + if (first != std::numeric_limits::max() && first > last) { + return absl::nullopt; + } + + parsed_ranges.push_back(RawByteRange(first.value(), last.value())); + } + + return parsed_ranges; +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/range_utils.h b/source/extensions/filters/http/cache_v2/range_utils.h new file mode 100644 index 0000000000000..3baeac626d342 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/range_utils.h @@ -0,0 +1,137 @@ +#pragma once + +#include +#include +#include +#include + +#include "envoy/buffer/buffer.h" +#include "envoy/common/time.h" +#include "envoy/config/typed_config.h" +#include "envoy/extensions/filters/http/cache_v2/v3/cache.pb.h" +#include "envoy/http/header_map.h" +#include "envoy/stream_info/stream_info.h" + +#include "source/common/common/assert.h" +#include "source/common/common/logger.h" +#include "source/common/protobuf/message_validator_impl.h" +#include "source/common/protobuf/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/key.pb.h" + +#include "absl/strings/string_view.h" +#include "absl/types/optional.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// Byte range from an HTTP request. +class RawByteRange { +public: + // - If first==UINT64_MAX, construct a RawByteRange requesting the final last + // body bytes. + // - Otherwise, construct a RawByteRange requesting the [first,last] body + // bytes. Prereq: first == UINT64_MAX || first <= last Invariant: isSuffix() + // || firstBytePos() <= lastBytePos Examples: RawByteRange(0,4) requests the + // first 5 bytes. + // RawByteRange(UINT64_MAX,4) requests the last 4 bytes. + RawByteRange(uint64_t first, uint64_t last) : first_byte_pos_(first), last_byte_pos_(last) { + ASSERT(isSuffix() || first <= last, "Illegal byte range."); + } + bool isSuffix() const { return first_byte_pos_ == UINT64_MAX; } + uint64_t firstBytePos() const { + ASSERT(!isSuffix()); + return first_byte_pos_; + } + uint64_t lastBytePos() const { + ASSERT(!isSuffix()); + return last_byte_pos_; + } + uint64_t suffixLength() const { + ASSERT(isSuffix()); + return last_byte_pos_; + } + +private: + const uint64_t first_byte_pos_; + const uint64_t last_byte_pos_; +}; + +// Byte range from an HTTP request, adjusted for a known response body size, and +// converted from an HTTP-style closed interval to a C++ style half-open +// interval. +class AdjustedByteRange { +public: + // Construct an AdjustedByteRange representing the [first,last) bytes in the + // response body. Prereq: first <= last Invariant: begin() <= end() + // Example: AdjustedByteRange(0,4) represents the first 4 bytes. + AdjustedByteRange(uint64_t first, uint64_t last) : first_(first), last_(last) { + ASSERT(first < last, "Illegal byte range."); + } + uint64_t begin() const { return first_; } + + // Unlike RawByteRange, end() is one past the index of the last offset. + // + // If end() == std::numeric_limits::max(), the cache doesn't yet + // know the response body's length. + uint64_t end() const { return last_; } + uint64_t length() const { return last_ - first_; } + void trimFront(uint64_t n) { + ASSERT(n <= length(), "Attempt to trim too much from range."); + first_ += n; + } + +private: + uint64_t first_; + uint64_t last_; +}; + +inline bool operator==(const AdjustedByteRange& lhs, const AdjustedByteRange& rhs) { + return lhs.begin() == rhs.begin() && lhs.end() == rhs.end(); +} + +std::ostream& operator<<(std::ostream& os, const AdjustedByteRange& range); + +// Contains details about whether the ranges requested can be satisfied and, if +// so, what those ranges are after being adjusted to fit the content. +struct RangeDetails { + // Indicates whether the requested ranges can be satisfied by the content + // stored in the cache. If not, we need to go to the backend to fill the + // cache. + bool satisfiable_ = false; + // The ranges that will be served by the cache, if satisfiable_ = true. + std::vector ranges_; +}; + +namespace RangeUtils { +// Create a RangeDetails object from request headers and provided content +// length to assess whether the range request can be satisfied. nullopt +// indicates that this request should not be treated as a range request +// (either it is invalid and ignored, or not a range request at all). +absl::optional +createRangeDetails(const Envoy::Http::RequestHeaderMap& request_headers, uint64_t content_length); +absl::optional createRangeDetails(const absl::string_view range_header, + const uint64_t content_length); + +// Simple utility to extract the range header from the request header map. +absl::optional getRangeHeader(const Envoy::Http::RequestHeaderMap& headers); + +// Create RangeDetails indicating if the range request is satisfiable, and, if +// so, create adjusted byte ranges to fit the provided content_length. +RangeDetails createAdjustedRangeDetails(const std::vector& request_range_spec, + uint64_t content_length); + +// Parses the ranges from the request headers into a vector. +// max_byte_range_specs defines how many byte ranges can be parsed from the +// header value. If there is no range header, multiple range headers, the +// header value is malformed, or there are more ranges than +// max_byte_range_specs, returns nullopt. +absl::optional> parseRangeHeader(absl::string_view range_header, + uint64_t max_byte_range_specs); +} // namespace RangeUtils +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/stats.cc b/source/extensions/filters/http/cache_v2/stats.cc new file mode 100644 index 0000000000000..ab05a5b8e7762 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/stats.cc @@ -0,0 +1,142 @@ +#include "source/extensions/filters/http/cache_v2/stats.h" + +#include "envoy/stats/stats_macros.h" + +#include "absl/strings/str_replace.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +#define CACHE_FILTER_STATS(COUNTER, GAUGE, HISTOGRAM, TEXT_READOUT, STATNAME) \ + STATNAME(cache_sessions_entries) \ + STATNAME(cache_sessions_subscribers) \ + STATNAME(upstream_buffered_bytes) \ + STATNAME(cache) \ + STATNAME(cache_label) \ + STATNAME(event) \ + STATNAME(event_type) \ + STATNAME(hit) \ + STATNAME(miss) \ + STATNAME(failed_validation) \ + STATNAME(uncacheable) \ + STATNAME(upstream_reset) \ + STATNAME(lookup_error) \ + STATNAME(validate) + +MAKE_STAT_NAMES_STRUCT(CacheStatNames, CACHE_FILTER_STATS); + +using Envoy::Stats::Utility::counterFromStatNames; +using Envoy::Stats::Utility::gaugeFromStatNames; + +class CacheFilterStatsImpl : public CacheFilterStats { +public: + CacheFilterStatsImpl(Stats::Scope& scope, absl::string_view label) + : stat_names_(scope.symbolTable()), prefix_(stat_names_.cache_), + label_(stat_names_.pool_.add(absl::StrReplaceAll(label, {{".", "_"}}))), + tags_just_label_({{stat_names_.cache_label_, label_}}), + tags_hit_( + {{stat_names_.cache_label_, label_}, {stat_names_.event_type_, stat_names_.hit_}}), + tags_miss_( + {{stat_names_.cache_label_, label_}, {stat_names_.event_type_, stat_names_.miss_}}), + tags_failed_validation_({{stat_names_.cache_label_, label_}, + {stat_names_.event_type_, stat_names_.failed_validation_}}), + tags_uncacheable_({{stat_names_.cache_label_, label_}, + {stat_names_.event_type_, stat_names_.uncacheable_}}), + tags_upstream_reset_({{stat_names_.cache_label_, label_}, + {stat_names_.event_type_, stat_names_.upstream_reset_}}), + tags_lookup_error_({{stat_names_.cache_label_, label_}, + {stat_names_.event_type_, stat_names_.lookup_error_}}), + tags_validate_( + {{stat_names_.cache_label_, label_}, {stat_names_.event_type_, stat_names_.validate_}}), + gauge_cache_sessions_entries_( + gaugeFromStatNames(scope, {prefix_, stat_names_.cache_sessions_entries_}, + Stats::Gauge::ImportMode::NeverImport, tags_just_label_)), + gauge_cache_sessions_subscribers_( + gaugeFromStatNames(scope, {prefix_, stat_names_.cache_sessions_subscribers_}, + Stats::Gauge::ImportMode::NeverImport, tags_just_label_)), + gauge_upstream_buffered_bytes_( + gaugeFromStatNames(scope, {prefix_, stat_names_.upstream_buffered_bytes_}, + Stats::Gauge::ImportMode::NeverImport, tags_just_label_)), + counter_hit_(counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_hit_)), + counter_miss_(counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_miss_)), + counter_failed_validation_( + counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_failed_validation_)), + counter_uncacheable_( + counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_uncacheable_)), + counter_upstream_reset_( + counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_upstream_reset_)), + counter_lookup_error_( + counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_lookup_error_)), + counter_validate_( + counterFromStatNames(scope, {prefix_, stat_names_.event_}, tags_validate_)) {} + void incForStatus(CacheEntryStatus status) override; + void incCacheSessionsEntries() override { gauge_cache_sessions_entries_.inc(); } + void decCacheSessionsEntries() override { gauge_cache_sessions_entries_.dec(); } + void incCacheSessionsSubscribers() override { gauge_cache_sessions_subscribers_.inc(); } + void subCacheSessionsSubscribers(uint64_t count) override { + gauge_cache_sessions_subscribers_.sub(count); + } + void addUpstreamBufferedBytes(uint64_t bytes) override { + gauge_upstream_buffered_bytes_.add(bytes); + } + void subUpstreamBufferedBytes(uint64_t bytes) override { + gauge_upstream_buffered_bytes_.sub(bytes); + } + +private: + CacheFilterStatsImpl(CacheFilterStatsImpl&) = delete; + CacheStatNames stat_names_; + const Stats::StatName prefix_; + const Stats::StatName label_; + const Stats::StatNameTagVector tags_just_label_; + const Stats::StatNameTagVector tags_hit_; + const Stats::StatNameTagVector tags_miss_; + const Stats::StatNameTagVector tags_failed_validation_; + const Stats::StatNameTagVector tags_uncacheable_; + const Stats::StatNameTagVector tags_upstream_reset_; + const Stats::StatNameTagVector tags_lookup_error_; + const Stats::StatNameTagVector tags_validate_; + Stats::Gauge& gauge_cache_sessions_entries_; + Stats::Gauge& gauge_cache_sessions_subscribers_; + Stats::Gauge& gauge_upstream_buffered_bytes_; + Stats::Counter& counter_hit_; + Stats::Counter& counter_miss_; + Stats::Counter& counter_failed_validation_; + Stats::Counter& counter_uncacheable_; + Stats::Counter& counter_upstream_reset_; + Stats::Counter& counter_lookup_error_; + Stats::Counter& counter_validate_; +}; + +CacheFilterStatsPtr generateStats(Stats::Scope& scope, absl::string_view label) { + return std::make_unique(scope, label); +} + +void CacheFilterStatsImpl::incForStatus(CacheEntryStatus status) { + switch (status) { + case CacheEntryStatus::Miss: + return counter_miss_.inc(); + case CacheEntryStatus::FailedValidation: + return counter_failed_validation_.inc(); + case CacheEntryStatus::Hit: + case CacheEntryStatus::FoundNotModified: + case CacheEntryStatus::Follower: + case CacheEntryStatus::ValidatedFree: + return counter_hit_.inc(); + case CacheEntryStatus::Validated: + return counter_validate_.inc(); + case CacheEntryStatus::UpstreamReset: + return counter_upstream_reset_.inc(); + case CacheEntryStatus::Uncacheable: + return counter_uncacheable_.inc(); + case CacheEntryStatus::LookupError: + return counter_lookup_error_.inc(); + } +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/stats.h b/source/extensions/filters/http/cache_v2/stats.h new file mode 100644 index 0000000000000..f1d908c07fd15 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/stats.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class CacheFilterStats { +public: + virtual void incForStatus(CacheEntryStatus status) PURE; + virtual void incCacheSessionsEntries() PURE; + virtual void decCacheSessionsEntries() PURE; + virtual void incCacheSessionsSubscribers() PURE; + virtual void subCacheSessionsSubscribers(uint64_t count) PURE; + virtual void addUpstreamBufferedBytes(uint64_t bytes) PURE; + virtual void subUpstreamBufferedBytes(uint64_t bytes) PURE; + virtual ~CacheFilterStats() = default; +}; + +class CacheFilterStatsProvider { +public: + virtual CacheFilterStats& stats() const PURE; + virtual ~CacheFilterStatsProvider() = default; +}; + +using CacheFilterStatsPtr = std::unique_ptr; + +CacheFilterStatsPtr generateStats(Stats::Scope& scope, absl::string_view label); + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/upstream_request.h b/source/extensions/filters/http/cache_v2/upstream_request.h new file mode 100644 index 0000000000000..69d61fbbfb9f7 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/upstream_request.h @@ -0,0 +1,41 @@ +#pragma once + +#include "source/extensions/filters/http/cache_v2/http_source.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class CacheFilterStatsProvider; + +class UpstreamRequest : public HttpSource { +public: + virtual void sendHeaders(Http::RequestHeaderMapPtr headers) PURE; +}; + +using UpstreamRequestPtr = std::unique_ptr; + +// UpstreamRequest acts as a bridge between the "pull" operations preferred by +// the cache filter (getHeaders/getBody/getTrailers) and the "push" operations +// preferred by most of envoy (encodeHeaders etc. being called by the source). +// +// In order to bridge the two, UpstreamRequest must act as a buffer; on a get* +// request it calls back only when the buffer has [some of] the requested data +// in it; if the buffer gets overfull, watermark events are triggered on the +// upstream. The client side should only send get* requests when it is ready for +// more data, so the downstream is automatically resilient to OOM. +// TODO(#33319): AsyncClient::Stream does not currently support watermark events. +class UpstreamRequestFactory { +public: + virtual UpstreamRequestPtr + create(const std::shared_ptr stats_provider) PURE; + virtual ~UpstreamRequestFactory() = default; +}; + +using UpstreamRequestFactoryPtr = std::unique_ptr; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/upstream_request_impl.cc b/source/extensions/filters/http/cache_v2/upstream_request_impl.cc new file mode 100644 index 0000000000000..c4e7d077ca878 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/upstream_request_impl.cc @@ -0,0 +1,212 @@ +#include "source/extensions/filters/http/cache_v2/upstream_request_impl.h" + +#include "range_utils.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +UpstreamRequestPtr UpstreamRequestImplFactory::create( + const std::shared_ptr stats_provider) { + // Can't use make_unique because the constructor is private. + auto ret = std::unique_ptr(new UpstreamRequestImpl( + dispatcher_, async_client_, stream_options_, std::move(stats_provider))); + return ret; +} + +UpstreamRequestImpl::UpstreamRequestImpl( + Event::Dispatcher& dispatcher, Http::AsyncClient& async_client, + const Http::AsyncClient::StreamOptions& options, + const std::shared_ptr stats_provider) + : dispatcher_(dispatcher), stream_(async_client.start(*this, options)), + body_buffer_([this]() { onBelowLowWatermark(); }, [this]() { onAboveHighWatermark(); }, + nullptr), + stats_provider_(std::move(stats_provider)) { + ASSERT(stream_ != nullptr); + body_buffer_.setWatermarks(options.buffer_limit_.value_or(0)); +} + +void UpstreamRequestImpl::onAboveHighWatermark() { + ASSERT(dispatcher_.isThreadSafe()); + // TODO(ravenblack): currently AsyncRequest::Stream does not support pausing. + // Waiting on issue #33319 +} + +void UpstreamRequestImpl::onBelowLowWatermark() { + ASSERT(dispatcher_.isThreadSafe()); + // TODO(ravenblack): currently AsyncRequest::Stream does not support pausing. + // Waiting on issue #33319 +} + +void UpstreamRequestImpl::getHeaders(GetHeadersCallback&& cb) { + ASSERT(dispatcher_.isThreadSafe()); + ASSERT(absl::holds_alternative(callback_)); + if (!stream_ && !end_stream_after_headers_ && !end_stream_after_body_ && !trailers_) { + return cb(nullptr, EndStream::Reset); + } + callback_ = std::move(cb); + return maybeDeliverHeaders(); +} + +void UpstreamRequestImpl::onHeaders(Http::ResponseHeaderMapPtr&& headers, bool end_stream) { + ASSERT(dispatcher_.isThreadSafe()); + headers_ = std::move(headers); + end_stream_after_headers_ = end_stream; + return maybeDeliverHeaders(); +} + +void UpstreamRequestImpl::maybeDeliverHeaders() { + ASSERT(dispatcher_.isThreadSafe()); + if (!absl::holds_alternative(callback_) || !headers_) { + return; + } + return absl::get(consumeCallback())( + std::move(headers_), end_stream_after_headers_ ? EndStream::End : EndStream::More); +} + +void UpstreamRequestImpl::getBody(AdjustedByteRange range, GetBodyCallback&& cb) { + ASSERT(dispatcher_.isThreadSafe()); + ASSERT(absl::holds_alternative(callback_)); + ASSERT(range.begin() == stream_pos_, "UpstreamRequest does not support out of order reads"); + ASSERT(!end_stream_after_headers_); + if (!stream_ && !end_stream_after_body_ && !trailers_) { + return cb(nullptr, EndStream::Reset); + } + requested_body_range_ = std::move(range); + callback_ = std::move(cb); + return maybeDeliverBody(); +} + +void UpstreamRequestImpl::onData(Buffer::Instance& data, bool end_stream) { + ASSERT(dispatcher_.isThreadSafe()); + end_stream_after_body_ = end_stream; + stats().addUpstreamBufferedBytes(data.length()); + body_buffer_.move(data); + return maybeDeliverBody(); +} + +void UpstreamRequestImpl::maybeDeliverBody() { + ASSERT(dispatcher_.isThreadSafe()); + if (!absl::holds_alternative(callback_)) { + return; + } + uint64_t len = std::min(requested_body_range_.length(), body_buffer_.length()); + if (len == 0) { + if (trailers_) { + // If we've already seen trailers from upstream and there's no more buffered + // body, but the client is still requesting body, it means the client didn't + // know how much body to expect. A null body with end_stream=false informs the + // client to move on to requesting trailers. + return absl::get(consumeCallback())(nullptr, EndStream::More); + } + if (end_stream_after_body_) { + // If we already reached the end of message and are still requesting more + // body, a null buffer indicates the body ended. + return absl::get(consumeCallback())(nullptr, EndStream::End); + } + // If we have no body or end but have requested some body, that means we're + // just waiting for it to arrive, and maybeDeliverBody will be called again + // when that happens. + return; + } + auto fragment = std::make_unique(); + fragment->move(body_buffer_, len); + stream_pos_ += len; + stats().subUpstreamBufferedBytes(len); + bool end_stream = end_stream_after_body_ && body_buffer_.length() == 0; + return absl::get(consumeCallback())( + std::move(fragment), end_stream ? EndStream::End : EndStream::More); +} + +void UpstreamRequestImpl::getTrailers(GetTrailersCallback&& cb) { + ASSERT(dispatcher_.isThreadSafe()); + ASSERT(absl::holds_alternative(callback_)); + ASSERT(!end_stream_after_headers_ && !end_stream_after_body_); + if (!stream_ && !trailers_) { + return cb(nullptr, EndStream::Reset); + } + callback_ = std::move(cb); + return maybeDeliverTrailers(); +} + +void UpstreamRequestImpl::onTrailers(Http::ResponseTrailerMapPtr&& trailers) { + ASSERT(dispatcher_.isThreadSafe()); + trailers_ = std::move(trailers); + return maybeDeliverTrailers(); +} + +void UpstreamRequestImpl::maybeDeliverTrailers() { + ASSERT(dispatcher_.isThreadSafe()); + if (!absl::holds_alternative(callback_) || !trailers_) { + if (body_buffer_.length() == 0 && absl::holds_alternative(callback_)) { + // If we received trailers while requesting body it means that we didn't + // know how much body to request, or the upstream returned less body than + // expected by surprise - a null body response informs the client to + // request trailers instead. + return absl::get(consumeCallback())(nullptr, EndStream::More); + } + return; + } + return absl::get(consumeCallback())(std::move(trailers_), EndStream::End); +} + +UpstreamRequestImpl::~UpstreamRequestImpl() { + ASSERT(dispatcher_.isThreadSafe()); + // Cancel in-flight callbacks on destroy. + callback_ = absl::monostate{}; + cancel_(); + if (stream_) { + // Resets the stream and calls onReset, guaranteeing no further callbacks. + stream_->reset(); + } + if (body_buffer_.length() > 0) { + stats().subUpstreamBufferedBytes(body_buffer_.length()); + } +} + +void UpstreamRequestImpl::sendHeaders(Http::RequestHeaderMapPtr request_headers) { + ASSERT(dispatcher_.isThreadSafe()); + // UpstreamRequest must take a copy of the headers as the AsyncStream may + // still use the reference provided to it after the original reference has moved. + request_headers_ = std::move(request_headers); + // If this request had a body or trailers, CacheFilter::decodeHeaders + // would have bypassed cache lookup and insertion, so this class wouldn't + // be instantiated. So end_stream will always be true. + stream_->sendHeaders(*request_headers_, /*end_stream=*/true); + absl::optional range_header = RangeUtils::getRangeHeader(*request_headers_); + if (range_header) { + absl::optional> ranges = + RangeUtils::parseRangeHeader(range_header.value(), 1); + if (ranges) { + stream_pos_ = ranges.value().front().firstBytePos(); + } + } +} + +template struct overloaded : Ts... { + using Ts::operator()...; +}; +template overloaded(Ts...) -> overloaded; + +void UpstreamRequestImpl::onReset() { + ASSERT(dispatcher_.isThreadSafe()); + stream_ = nullptr; + absl::visit(overloaded{ + [](absl::monostate&&) {}, + [](GetHeadersCallback&& cb) { cb(nullptr, EndStream::Reset); }, + [](GetBodyCallback&& cb) { cb(nullptr, EndStream::Reset); }, + [](GetTrailersCallback&& cb) { cb(nullptr, EndStream::Reset); }, + }, + consumeCallback()); +} + +void UpstreamRequestImpl::onComplete() { + ASSERT(dispatcher_.isThreadSafe()); + stream_ = nullptr; +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache_v2/upstream_request_impl.h b/source/extensions/filters/http/cache_v2/upstream_request_impl.h new file mode 100644 index 0000000000000..21401d1356290 --- /dev/null +++ b/source/extensions/filters/http/cache_v2/upstream_request_impl.h @@ -0,0 +1,102 @@ +#pragma once + +#include "source/common/buffer/watermark_buffer.h" +#include "source/common/common/cancel_wrapper.h" +#include "source/common/common/logger.h" +#include "source/extensions/filters/http/cache_v2/http_source.h" +#include "source/extensions/filters/http/cache_v2/range_utils.h" +#include "source/extensions/filters/http/cache_v2/stats.h" +#include "source/extensions/filters/http/cache_v2/upstream_request.h" + +#include "absl/types/variant.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +class UpstreamRequestImpl : public Logger::Loggable, + public UpstreamRequest, + public Http::AsyncClient::StreamCallbacks { +public: + // Called from the factory. + void sendHeaders(Http::RequestHeaderMapPtr request_headers) override; + // HttpSource. + void getHeaders(GetHeadersCallback&& cb) override; + // Though range is an argument here, only the length is used by UpstreamRequest + // - the pieces requested should always be in order so we can just consume the + // stream as it comes. + void getBody(AdjustedByteRange range, GetBodyCallback&& cb) override; + void getTrailers(GetTrailersCallback&& cb) override; + + // StreamCallbacks + void onHeaders(Http::ResponseHeaderMapPtr&& headers, bool end_stream) override; + void onData(Buffer::Instance& data, bool end_stream) override; + void onTrailers(Http::ResponseTrailerMapPtr&& trailers) override; + void onComplete() override; + void onReset() override; + + // Called by WatermarkBuffer + void onAboveHighWatermark(); + void onBelowLowWatermark(); + + ~UpstreamRequestImpl() override; + +private: + friend class UpstreamRequestImplFactory; + UpstreamRequestImpl(Event::Dispatcher& dispatcher, Http::AsyncClient& async_client, + const Http::AsyncClient::StreamOptions& options, + const std::shared_ptr stats_provider); + // If the headers and callback are both present, call the callback. + void maybeDeliverHeaders(); + + // If the required body chunk and callback are both present, call the callback. + void maybeDeliverBody(); + + // If the trailers and callback are both present, call the callback. + void maybeDeliverTrailers(); + + using CallbackTypes = + absl::variant; + + // Returns the current callback and clears the member variable so it's safe to + // assert that it's empty. + CallbackTypes consumeCallback() { return std::exchange(callback_, absl::monostate{}); } + + CacheFilterStats& stats() const { return stats_provider_->stats(); } + + Event::Dispatcher& dispatcher_; + Http::AsyncClient::Stream* stream_; + Http::RequestHeaderMapPtr request_headers_; + Http::ResponseHeaderMapPtr headers_; + CallbackTypes callback_; + bool end_stream_after_headers_{false}; + Buffer::WatermarkBuffer body_buffer_; + AdjustedByteRange requested_body_range_{0, 1}; + uint64_t stream_pos_ = 0; + bool end_stream_after_body_{false}; + Http::ResponseTrailerMapPtr trailers_; + CancelWrapper::CancelFunction cancel_ = []() {}; + const std::shared_ptr stats_provider_; +}; + +class UpstreamRequestImplFactory : public UpstreamRequestFactory { +public: + UpstreamRequestImplFactory(Event::Dispatcher& dispatcher, Http::AsyncClient& async_client, + Http::AsyncClient::StreamOptions stream_options) + : dispatcher_(dispatcher), async_client_(async_client), + stream_options_(std::move(stream_options)) {} + + UpstreamRequestPtr + create(const std::shared_ptr stats_provider) override; + +private: + Event::Dispatcher& dispatcher_; + Http::AsyncClient& async_client_; + Http::AsyncClient::StreamOptions stream_options_; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/BUILD b/source/extensions/http/cache_v2/file_system_http_cache/BUILD new file mode 100644 index 0000000000000..65b89ce93cb87 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/BUILD @@ -0,0 +1,85 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", + "envoy_proto_library", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_proto_library( + name = "cache_file_header_proto", + srcs = ["cache_file_header.proto"], + deps = ["//source/extensions/filters/http/cache_v2:key"], +) + +envoy_cc_extension( + name = "config", + srcs = [ + "cache_eviction_thread.cc", + "cache_file_reader.cc", + "config.cc", + "file_system_http_cache.cc", + "insert_context.cc", + "lookup_context.cc", + "stats.cc", + ], + hdrs = [ + "cache_eviction_thread.h", + "cache_file_reader.h", + "file_system_http_cache.h", + "insert_context.h", + "lookup_context.h", + "stats.h", + ], + deps = [ + ":cache_file_fixed_block", + ":cache_file_header_proto_cc_proto", + ":cache_file_header_proto_util", + "//envoy/common:time_interface", + "//envoy/http:header_map_interface", + "//envoy/registry", + "//source/common/buffer:buffer_lib", + "//source/common/common:macros", + "//source/common/common:safe_memcpy_lib", + "//source/common/filesystem:directory_lib", + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf", + "//source/extensions/common/async_files", + "//source/extensions/filters/http/cache_v2:cache_sessions_impl_lib", + "//source/extensions/filters/http/cache_v2:http_cache_lib", + "@com_google_absl//absl/base", + "@com_google_absl//absl/strings", + "@com_google_absl//absl/types:optional", + "@envoy_api//envoy/extensions/http/cache_v2/file_system_http_cache/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "cache_file_header_proto_util", + srcs = ["cache_file_header_proto_util.cc"], + hdrs = ["cache_file_header_proto_util.h"], + deps = [ + ":cache_file_header_proto_cc_proto", + "//envoy/http:header_map_interface", + "//source/common/buffer:buffer_lib", + "//source/common/common:macros", + "//source/extensions/filters/http/cache_v2:http_cache_lib", + "@com_google_absl//absl/container:flat_hash_set", + "@com_google_absl//absl/strings", + ], +) + +envoy_cc_library( + name = "cache_file_fixed_block", + srcs = ["cache_file_fixed_block.cc"], + hdrs = ["cache_file_fixed_block.h"], + deps = [ + "//envoy/buffer:buffer_interface", + "@com_google_absl//absl/strings", + ], +) diff --git a/source/extensions/http/cache_v2/file_system_http_cache/DESIGN.md b/source/extensions/http/cache_v2/file_system_http_cache/DESIGN.md new file mode 100644 index 0000000000000..d4486576eafa8 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/DESIGN.md @@ -0,0 +1,30 @@ +# File system cache design + +## Goals + +(Unchecked boxes are not yet achieved; checked boxes are implemented features) + +- [x] Cache should be usable by two processes at once (e.g. during hot restart) +- [x] Cache should evict the least recently used (LRU) entry when full +- [ ] Eviction should be configurable as a "window", like watermarks, or with an optional frequency constraint, so the eviction thread can be kept from churning. +- [x] Cache should be limited to a specified amount of storage +- [ ] Cache should be configurable to periodically update the internal size from the filesystem, to account for external alterations. +- [ ] There should be an ability to remove objects from the cache with some kind of API call. +- [ ] Cache should expose counters for eviction stats (files evicted, bytes evicted). +- [ ] Cache should expose counters for timing information (eviction thread idle, eviction thread busy) +- [x] Cache should expose gauges for total size stored. +- [ ] Cache should optionally expose histogram for cache entry sizes. +- [x] Cache should index by the request route *and* a key generated from headers that may affect the outcome of a request (See [allowed_vary_headers](https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/cache_v2/v3/cache.proto.html)) +- [ ] Cache should create a [tree structure](#tree-structure) of folders (may be configured as just one branch), so user may avoid filesystem performance issues with overcrowded directories. +- [ ] Cache should validate the existence of the file path it is configured to use, at startup. (Maybe optionally try to create it if not present?) + +## Storage design + +* A `CacheSession` maintains an open file handle of which ownership is passed to the `CacheSession`. It is possible for such an entry to be evicted (on a validation fail most likely), which should be fine - the file will be unlinked and the open file handle will keep the data "alive" until the requests using the old file handle are completed. +* Simultaneous writes don't break anything, and may occur when multiple processes are touching the same cache. +* The cache can be configured with a maximum number of cache entry files, thereby effectively enforcing a maximum number of files per path. +* A new cache entry that causes the cache to exceed the configured maximum size or maximum number of entries triggers the eviction thread to evict sufficient LRU entries to bring it back below the threshold\[s\] exceeded. +* Each cache entry file starts with [a fixed structure header followed by a serialized proto](cache_file_header.proto), followed by raw body, proto-serialized trailers and proto-serialized headers. Headers are at the end to facilitate updating headers on validate operations. +* Cache entry files are named `cache-` followed by a stable hash key for the entry. + +* (When implemented) the tree structure of folders is simply one level deep of folders named `cache-0000`, `cache-0001` etc. as four-digit hexadecimal numbers up to the configured number of subdirectories. Cache files are placed in a folder according to a short stable hash of their key. On cache startup, any cache entries found to be in the wrong folder (as would be the case if the number of folders was reconfigured) will simply be removed. diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.cc b/source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.cc new file mode 100644 index 0000000000000..9ef27fcbffcc8 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.cc @@ -0,0 +1,195 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h" + +#include + +#include "envoy/thread/thread.h" + +#include "source/common/api/os_sys_calls_impl.h" +#include "source/common/filesystem/directory.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +namespace { +bool isCacheFile(const Filesystem::DirectoryEntry& entry) { + return entry.type_ == Filesystem::FileType::Regular && absl::StartsWith(entry.name_, "cache-"); +} +} // namespace + +CacheEvictionThread::CacheEvictionThread(Thread::ThreadFactory& thread_factory) + : thread_(thread_factory.createThread([this]() { work(); })) {} + +CacheEvictionThread::~CacheEvictionThread() { + terminate(); + thread_->join(); +} + +void CacheEvictionThread::addCache(std::shared_ptr cache) { + { + absl::MutexLock lock(&cache_mu_); + bool inserted = caches_.emplace(std::move(cache)).second; + ASSERT(inserted); + } + // Signal to unblock CacheEvictionThread to perform the initial cache measurement + // (and possibly eviction if it's starting out oversized!) + signal(); +} + +void CacheEvictionThread::removeCache(std::shared_ptr& cache) { + absl::MutexLock lock(&cache_mu_); + bool removed = caches_.erase(cache); + ASSERT(removed); +} + +void CacheEvictionThread::signal() { + absl::MutexLock lock(&mu_); + signalled_ = true; +} + +void CacheEvictionThread::terminate() { + absl::MutexLock lock(&mu_); + terminating_ = true; + signalled_ = true; +} + +bool CacheEvictionThread::waitForSignal() { + absl::MutexLock lock(&mu_); + // Worth noting here that if `signalled_` is already true, the lock is not released + // until idle_ is false again, so waitForIdle will not return until `signalled_` + // stays false for the duration of an eviction cycle. + idle_ = true; + mu_.Await(absl::Condition(&signalled_)); + signalled_ = false; + idle_ = false; + return !terminating_; +} + +void CacheShared::initStats() { + if (config_.has_max_cache_size_bytes()) { + stats_.size_limit_bytes_.set(config_.max_cache_size_bytes().value()); + } + if (config_.has_max_cache_entry_count()) { + stats_.size_limit_count_.set(config_.max_cache_entry_count().value()); + } + // TODO(ravenblack): Add support for directory tree structure. + for (const Filesystem::DirectoryEntry& entry : Filesystem::Directory(std::string{cachePath()})) { + if (!isCacheFile(entry)) { + continue; + } + size_count_++; + size_bytes_ += entry.size_bytes_.value_or(0); + } + stats_.size_count_.set(size_count_); + stats_.size_bytes_.set(size_bytes_); + needs_init_ = false; +} + +void CacheShared::evict() { + stats_.eviction_runs_.add(1); + auto os_sys_calls = Api::OsSysCallsSingleton::get(); + uint64_t size = 0; + uint64_t count = 0; + struct CacheFile { + std::string name_; + uint64_t size_; + Envoy::SystemTime last_touch_; + }; + std::vector cache_files; + + // TODO(ravenblack): Add support for directory tree structure. + for (const Filesystem::DirectoryEntry& entry : Filesystem::Directory(std::string{cachePath()})) { + if (!isCacheFile(entry)) { + continue; + } + count++; + size += entry.size_bytes_.value_or(0); + struct stat s; + if (os_sys_calls.stat(absl::StrCat(cachePath(), entry.name_).c_str(), &s).return_value_ != -1) { +#ifdef _DARWIN_FEATURE_64_BIT_INODE + Envoy::SystemTime last_touch = + std::max(timespecToChrono(s.st_atimespec), timespecToChrono(s.st_ctimespec)); +#else + Envoy::SystemTime last_touch = + std::max(timespecToChrono(s.st_atim), timespecToChrono(s.st_ctim)); +#endif + + cache_files.push_back(CacheFile{entry.name_, entry.size_bytes_.value_or(0), last_touch}); + } + } + // Sort the vector by last-touch timestamp, highest (i.e. youngest) first. + std::sort(cache_files.begin(), cache_files.end(), [](CacheFile& a, CacheFile& b) { + return std::tie(a.last_touch_, a.name_) > std::tie(b.last_touch_, b.name_); + }); + size_bytes_ = size; + size_count_ = count; + stats_.size_bytes_.set(size); + stats_.size_count_.set(count); + uint64_t size_kept = 0; + uint64_t count_kept = 0; + uint64_t max_size = config_.has_max_cache_size_bytes() ? config_.max_cache_size_bytes().value() + : std::numeric_limits::max(); + uint64_t max_count = config_.has_max_cache_entry_count() ? config_.max_cache_entry_count().value() + : std::numeric_limits::max(); + auto it = cache_files.begin(); + // Keep the youngest files that won't exceed the limit. + while (it != cache_files.end() && size_kept + it->size_ <= max_size && + count_kept + 1 <= max_count) { + size_kept += it->size_; + count_kept++; + ++it; + } + // Evict the rest. + while (it != cache_files.end()) { + if (os_sys_calls.unlink(absl::StrCat(cachePath(), it->name_).c_str()).return_value_ != -1) { + // May want to add logging here for cache eviction failure, but it's expected sometimes, + // e.g. if another instance of Envoy is performing cleanup at the same time, or some external + // operator deleted the file. If it fails we don't reduce the estimated cache size, so another + // eviction run will happen sooner. + // TODO(ravenblack): might be worth checking the type of the error, or whether the file is + // gone - if there's a permissions issue, for example, then the cache might remain oversized + // and the eviction thread will be churning, trying and failing to remove a file, which would + // be worth logging a warning, versus if the file is already gone then there's no problem. + trackFileRemoved(it->size_); + } + ++it; + } +} + +void CacheEvictionThread::work() { + ENVOY_LOG(info, "Starting cache eviction thread."); + while (waitForSignal()) { + absl::flat_hash_set> caches; + { + // Take a local copy of the set of caches, so we don't hold the lock while + // work is being performed. + absl::MutexLock lock(&cache_mu_); + caches = caches_; + } + + for (const std::shared_ptr& cache : caches) { + if (cache->needs_init_) { + cache->initStats(); + } + if (cache->needsEviction()) { + cache->evict(); + } + } + } + ENVOY_LOG(info, "Ending cache eviction thread."); +} + +void CacheEvictionThread::waitForIdle() { + absl::MutexLock lock(&mu_); + auto cond = [this]() ABSL_EXCLUSIVE_LOCKS_REQUIRED(mu_) { return idle_ && !signalled_; }; + mu_.Await(absl::Condition(&cond)); +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h b/source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h new file mode 100644 index 0000000000000..c16d44d61ee68 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include + +#include "envoy/thread/thread.h" + +#include "source/common/common/logger.h" + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_set.h" +#include "absl/synchronization/mutex.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +struct CacheShared; + +/** + * A class which controls a thread on which cache evictions for all instances + * of FileSystemHttpCache are performed. + * + * The instance of CacheEvictionThread is owned by the `CacheSingleton`, which is + * created only when a first cache instance is created, and destroyed only when + * all cache instances have been destroyed. + * + * The class is final, as the thread may still be running during the destructor + * - this is fine so long as no class members or vtable entries have yet been + * destroyed, which can be guaranteed if the class is final. + * + * See DESIGN.md for more details of the eviction process. + **/ +class CacheEvictionThread final : public Logger::Loggable { +public: + CacheEvictionThread(Thread::ThreadFactory& thread_factory); + + /** + * The destructor may block until the cache eviction thread is joined. + */ + ~CacheEvictionThread(); + + /** + * Adds the given cache to the caches that may be evicted from. + * @param cache an unowned reference to the cache in question. + */ + void addCache(std::shared_ptr cache); + + /** + * Removes the given cache from the caches that may be evicted from. + * @param cache an unowned reference to the cache in question. + */ + void removeCache(std::shared_ptr& cache); + + /** + * Signals the cache eviction thread that it's time to check the current cache + * state against any configured limits, and perform eviction if necessary. + */ + void signal(); + +private: + /** + * The function that runs on the thread. + * + * This thread is expected to spend most of its time blocked, waiting for either + * `signal` or `terminate` to be called, or a configured period. + * + * When unblocked, the thread will exit if terminating_ is set. + * + * Otherwise, each cache instance's `needsEviction` function is called, in an + * arbitrary order, and, if that returns true, the `evict` function is also called. + * + * If `signal` is called during the eviction process, the eviction + * cycle may run a second time after completion, depending on configured + * constraints. + */ + void work(); + + /** + * @return false if terminating, true if `signalled_` is true or the run-again period + * has passed. + */ + bool waitForSignal(); + + /** + * Notifies the thread to terminate. If it is currently evicting, it will + * complete the current eviction cycle before exiting. If it is currently + * idle, it will exit immediately (terminate does not wait for exiting + * to be complete). + */ + void terminate(); + + // These two mutexes are never held at the same time. We signify this by requiring + // that both be 'acquired before' the other, since there is no exclusion annotation. + absl::Mutex mu_ ABSL_ACQUIRED_BEFORE(cache_mu_); + bool signalled_ ABSL_GUARDED_BY(mu_) = false; + bool terminating_ ABSL_GUARDED_BY(mu_) = false; + + absl::Mutex cache_mu_ ABSL_ACQUIRED_BEFORE(mu_); + // We must store the caches as unowned references so they can be destroyed + // during config changes - that destruction is the only signal that a cache + // instance should be removed. + absl::flat_hash_set> caches_ ABSL_GUARDED_BY(cache_mu_); + + // Allow test access to waitForIdle for synchronization. + friend class FileSystemCacheTestContext; + bool idle_ ABSL_GUARDED_BY(mu_) = false; + void waitForIdle(); + + // It is important that thread_ be last, as the new thread runs with 'this' and + // may access any other members. If thread_ is not last, there can be a race between + // that thread and the initialization of other members. + Thread::ThreadPtr thread_; +}; + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.cc b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.cc new file mode 100644 index 0000000000000..24fd3a844fe0a --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.cc @@ -0,0 +1,69 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" + +#include "source/common/common/assert.h" +#include "source/common/common/safe_memcpy.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +namespace { +// The expected first four bytes of the header - if fileId() doesn't match ExpectedFileId +// then the file is not a cache file and should be removed from the cache. +// Beginning of file should be "CACH". +constexpr std::array ExpectedFileId = {'C', 'A', 'C', 'H'}; + +// The expected next four bytes of the header - if cacheVersionId() doesn't match +// ExpectedCacheVersionId then the file is from an incompatible cache version and should +// be removed from the cache. +// Next 4 bytes of file should be "0002". +constexpr std::array ExpectedCacheVersionId = {'0', '0', '0', '2'}; + +} // namespace + +CacheFileFixedBlock::CacheFileFixedBlock() + : file_id_(ExpectedFileId), cache_version_id_(ExpectedCacheVersionId) {} + +void CacheFileFixedBlock::populateFromStringView(absl::string_view s) { + // The string view should be the size of the buffer, and + // Since we're explicitly reading byte offsets here, the size should match + // what we read. + // (This will remind us to change this function if we change the size + // and vice-versa!) + ASSERT(s.size() == size() && size() == 24); + // Serialize the values from the string_view s into the member values. + std::copy(s.begin(), s.begin() + 4, file_id_.begin()); + std::copy(s.begin() + 4, s.begin() + 8, cache_version_id_.begin()); + header_size_ = absl::big_endian::Load32(&s[8]); + trailer_size_ = absl::big_endian::Load32(&s[12]); + body_size_ = absl::big_endian::Load64(&s[16]); +} + +void CacheFileFixedBlock::serializeToBuffer(Buffer::Instance& buffer) { + char b[size()]; + // Since we're explicitly writing byte offsets here, the size should match + // what we write. + // (This will remind us to change this function if we change the size + // and vice-versa!) + ASSERT(size() == 24); + // Serialize the values from the member values into the stack buffer b. + std::copy(file_id_.begin(), file_id_.end(), &b[0]); + std::copy(cache_version_id_.begin(), cache_version_id_.end(), &b[4]); + absl::big_endian::Store32(&b[8], header_size_); + absl::big_endian::Store32(&b[12], trailer_size_); + absl::big_endian::Store64(&b[16], body_size_); + // Append that buffer into the target buffer object. + buffer.add(absl::string_view{b, size()}); +} + +bool CacheFileFixedBlock::isValid() const { + return fileId() == ExpectedFileId && cacheVersionId() == ExpectedCacheVersionId; +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h new file mode 100644 index 0000000000000..058a3b8b111fc --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h @@ -0,0 +1,145 @@ +#pragma once + +#include + +#include +#include + +#include "envoy/buffer/buffer.h" + +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +/** + * CacheFileFixedBlock represents a minimal header block on the cache entry file; it + * uses a struct that is required to be packed, and explicit byte order, to ensure + * consistency. (It is not *expected* that a cache file will be handled by different + * machines, but it costs little to accommodate it, and can simplify issue + * investigation if the file format doesn't vary.) + * + * This data is serialized as a flat object rather than protobuf serialization because it + * needs to be at the start of the file for efficient read, but write after the rest of the + * file has been completely written (as the body size and trailer size aren't necessarily + * known until the entire content has been streamed). Serialized proto messages can + * change size when values change, which makes them unsuited for this purpose. + */ +class CacheFileFixedBlock { +public: + /** + * the constructor initializes with the current compile-time constant values + * for fileId and cacheVersionId, and zero for all other member values. + */ + CacheFileFixedBlock(); + + /** + * deserializes the string representation of a CacheFileFixedBlock into this instance. + * @param str The string_view from which to populate the block. + */ + void populateFromStringView(absl::string_view str); + + /** + * appends the serialized fixed header chunk onto a buffer. + * @param buffer the buffer onto which to append the serialized fixed header chunk. + */ + void serializeToBuffer(Buffer::Instance& buffer); + + /** + * the size in bytes of a serialized CacheFileFixedBlock. This is compile-time constant. + * fileId, cacheVersionId, headerSize and trailerSize serialize to 4 bytes each. + * bodySize serializes to an 8-byte uint. + * @return the size in bytes. + */ + static constexpr uint32_t size() { return sizeof(uint32_t) * 4 + sizeof(uint64_t); } + + /** + * fileId is a compile-time fixed value used to identify that this is a cache file. + * @return the file ID. + */ + std::array fileId() const { return file_id_; } + + /** + * cacheVersionId is a compile-time fixed value that should be consistent between + * versions of the file cache implementation. Changing version in code will + * invalidate all cache entries where the version ID does not match. + * @return the cache version ID. + */ + std::array cacheVersionId() const { return cache_version_id_; } + + /** + * the size of the serialized proto message capturing headers and metadata. + * @return the size in bytes. + */ + uint32_t headerSize() const { return header_size_; } + + /** + * the size of the http body of the cache entry. + * @return the size in bytes. + */ + uint64_t bodySize() const { return body_size_; } + + /** + * the size of the serialized proto message capturing trailers. + * @return the size in bytes. + */ + uint32_t trailerSize() const { return trailer_size_; } + + /** + * sets the size of the serialized http headers, plus key and metadata, in the header block. + * @param sz The size of the serialized headers, key and metadata. + */ + void setHeadersSize(uint32_t sz) { header_size_ = sz; } + + /** + * sets the size of the serialized body in the header block. + * @param sz The size of the body data. + */ + void setBodySize(uint64_t sz) { body_size_ = sz; } + + /** + * sets the size of the serialized trailers in the header block. + * @param sz The size of the serialized trailers. + */ + void setTrailersSize(uint32_t sz) { trailer_size_ = sz; } + + /** + * the offset from the start of the file to the start of the body data. + * @return the offset in bytes. + */ + static uint64_t offsetToBody() { return size(); } + + /** + * the offset from the start of the file to the start of the serialized trailers proto. + * @return the offset in bytes. + */ + uint64_t offsetToTrailers() const { return offsetToBody() + bodySize(); } + + /** + * the offset from the start of the file to the start of the serialized headers proto. + * @return the offset in bytes. + */ + uint64_t offsetToHeaders() const { return offsetToTrailers() + trailerSize(); } + + /** + * is this a valid cache file header block for the current code version? + * @return True if the block's cache version id and file id match the current version. + */ + bool isValid() const; + +private: + std::array file_id_; + std::array cache_version_id_; + uint32_t header_size_{0}; + uint32_t trailer_size_{0}; + uint64_t body_size_{0}; +}; + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.proto b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.proto new file mode 100644 index 0000000000000..e3c1773b5d90b --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.proto @@ -0,0 +1,51 @@ +syntax = "proto3"; + +package Envoy.Extensions.HttpFilters.CacheV2.FileSystemHttpCache; + +import "google/protobuf/timestamp.proto"; +import "source/extensions/filters/http/cache_v2/key.proto"; + +// The full structure of a cache file is: +// 4 byte cache file identifier (used to ignore files that don't belong to the cache) +// 4 byte cache version identifier (if mismatched, the cache file is invalid and is deleted) +// 4 byte header size +// 4 byte trailer size +// 8 byte body size +// serialized CacheFileHeader +// body +// serialized CacheFileTrailer +// +// The opening block is necessary to allow the sizes to be at the front of the file, but +// (necessarily) written last - you can't easily insert things into a serialized proto, so +// a flat layout for this block is necessary. +// +// One slightly special case is the cache file for an entry with 'vary' headers involved +// - for this case at the 'hub' entry there is no trailer or body, and the only header +// is a 'vary' header, which indicates that the actual cache key will include some headers +// from the request. + +// For serializing to cache files only, the CacheFileHeader message contains the cache +// entry key, the cache metadata, and the http response headers. +message CacheFileHeader { + Key key = 1; + google.protobuf.Timestamp metadata_response_time = 2; + // Repeated Header messages are used, rather than a proto map, because there may be + // repeated keys, and ordering may be important. + message Header { + string key = 1; + string value = 2; + } + repeated Header headers = 3; +}; + +// For serializing to cache files only, the CacheFileTrailer message contains the http +// response trailers. +message CacheFileTrailer { + // Repeated Trailer messages are used, rather than a proto map, because there may be + // repeated keys, and ordering may be important. + message Trailer { + string key = 1; + string value = 2; + } + repeated Trailer trailers = 3; +}; diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.cc b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.cc new file mode 100644 index 0000000000000..5336a5c899096 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.cc @@ -0,0 +1,104 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h" + +#include "absl/container/flat_hash_set.h" +#include "absl/strings/str_cat.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { +namespace { +template +Http::HeaderMap::Iterate copyToKeyValue(const Http::HeaderEntry& header, KeyValue* kv) { + kv->set_key(std::string{header.key().getStringView()}); + kv->set_value(std::string{header.value().getStringView()}); + return Http::HeaderMap::Iterate::Continue; +} +} // namespace + +CacheFileHeader mergeProtoWithHeadersAndMetadata(const CacheFileHeader& entry_headers, + const Http::ResponseHeaderMap& response_headers, + const ResponseMetadata& response_metadata) { + Http::ResponseHeaderMapPtr merge_headers = headersFromHeaderProto(entry_headers); + applyHeaderUpdate(response_headers, *merge_headers); + return makeCacheFileHeaderProto(entry_headers.key(), *merge_headers, response_metadata); +} + +CacheFileHeader makeCacheFileHeaderProto(const Key& key, + const Http::ResponseHeaderMap& response_headers, + const ResponseMetadata& metadata) { + CacheFileHeader file_header; + *file_header.mutable_key() = key; + TimestampUtil::systemClockToTimestamp(metadata.response_time_, + *file_header.mutable_metadata_response_time()); + response_headers.iterate([&file_header](const Http::HeaderEntry& header) { + return copyToKeyValue(header, file_header.add_headers()); + }); + return file_header; +} + +CacheFileTrailer makeCacheFileTrailerProto(const Http::ResponseTrailerMap& response_trailers) { + CacheFileTrailer file_trailer; + response_trailers.iterate([&file_trailer](const Http::HeaderEntry& trailer) { + return copyToKeyValue(trailer, file_trailer.add_trailers()); + }); + return file_trailer; +} + +size_t headerProtoSize(const CacheFileHeader& proto) { return proto.ByteSizeLong(); } + +Buffer::OwnedImpl bufferFromProto(const CacheFileHeader& proto) { + // TODO(ravenblack): consider proto.SerializeToZeroCopyStream with an impl to Buffer. + return Buffer::OwnedImpl{proto.SerializeAsString()}; +} + +Buffer::OwnedImpl bufferFromProto(const CacheFileTrailer& proto) { + // TODO(ravenblack): consider proto.SerializeToZeroCopyStream with an impl to Buffer. + return Buffer::OwnedImpl{proto.SerializeAsString()}; +} + +std::string serializedStringFromProto(const CacheFileHeader& proto) { + return proto.SerializeAsString(); +} + +Http::ResponseHeaderMapPtr headersFromHeaderProto(const CacheFileHeader& header) { + Http::ResponseHeaderMapPtr headers = Http::ResponseHeaderMapImpl::create(); + for (const CacheFileHeader::Header& h : header.headers()) { + headers->addCopy(Http::LowerCaseString(h.key()), h.value()); + } + return headers; +} + +Http::ResponseTrailerMapPtr trailersFromTrailerProto(const CacheFileTrailer& trailer) { + Http::ResponseTrailerMapPtr trailers = Http::ResponseTrailerMapImpl::create(); + for (const CacheFileTrailer::Trailer& t : trailer.trailers()) { + trailers->addCopy(Http::LowerCaseString(t.key()), t.value()); + } + return trailers; +} + +ResponseMetadata metadataFromHeaderProto(const CacheFileHeader& header) { + ResponseMetadata metadata; + metadata.response_time_ = SystemTime{std::chrono::milliseconds( + Protobuf::util::TimeUtil::TimestampToMilliseconds(header.metadata_response_time()))}; + return metadata; +} + +CacheFileHeader makeCacheFileHeaderProto(Buffer::Instance& buffer) { + CacheFileHeader ret; + ret.ParseFromString(buffer.toString()); + return ret; +} + +CacheFileTrailer makeCacheFileTrailerProto(Buffer::Instance& buffer) { + CacheFileTrailer ret; + ret.ParseFromString(buffer.toString()); + return ret; +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h new file mode 100644 index 0000000000000..25d77d8ead471 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h @@ -0,0 +1,111 @@ +#pragma once + +#include "envoy/http/header_map.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.pb.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +/** + * Update an existing CacheFileHeader with new values from an updateHeaders operation. + * See applyHeaderUpdate in cache_entry_utils.h for details of merge behavior. + * @param entry_header the CacheFileHeader from the entry to be updated. + * @param response_headers the http headers from the updateHeaders call. + * @param response_metadata the metadata from the updateHeaders call. + * @return the merged CacheFileHeader. + */ +CacheFileHeader mergeProtoWithHeadersAndMetadata(const CacheFileHeader& entry_headers, + const Http::ResponseHeaderMap& response_headers, + const ResponseMetadata& response_metadata); + +/** + * Create a CacheFileHeader message from response headers, metadata and key. + * @param key the cache entry key. + * @param response_headers the response_headers from updateHeaders or insertHeaders. + * @param metadata the metadata from updateHeaders or insertHeaders. + * @return a CacheFileHeader proto containing the key, response headers and metadata. + */ +CacheFileHeader makeCacheFileHeaderProto(const Key& key, + const Http::ResponseHeaderMap& response_headers, + const ResponseMetadata& metadata); + +/** + * Create a CacheFilterTrailer message from response trailers. + * @param response_trailers the response_trailers from insertTrailers. + * @return a CacheFileTrailer message containing the http trailers. + */ +CacheFileTrailer makeCacheFileTrailerProto(const Http::ResponseTrailerMap& response_trailers); + +/** + * Serializes the CacheFileHeader proto and returns its size in bytes. + * @param proto the CacheFileHeader proto to have its serialized size measured. + */ +size_t headerProtoSize(const CacheFileHeader& proto); + +/** + * Serializes the CacheFileHeader proto into a Buffer object. + * @param proto the CacheFileHeader proto to be serialized. + * @return a Buffer::OwnedImpl containing the serialized CacheFileHeader. + */ +Buffer::OwnedImpl bufferFromProto(const CacheFileHeader& proto); + +/** + * Serializes the CacheFileTrailer proto into a Buffer object. + * @param proto the CacheFileTrailer proto to be serialized. + * @return a Buffer::OwnedImpl containing the serialized CacheFileTrailer. + */ +Buffer::OwnedImpl bufferFromProto(const CacheFileTrailer& proto); + +/** + * Serializes the CacheFileHeader proto into a std::string. + * @param proto the CacheFileHeader proto to be serialized. + * @return a std::string containing the serialized CacheFileHeader. + */ +std::string serializedStringFromProto(const CacheFileHeader& proto); + +/** + * Gets the headers from a CacheFileHeader message as an Envoy::Http::ResponseHeaderMapPtr. + * @param header the CacheFileHeader message from which to extract the headers. + * @return an Http::ResponseHeaderMapPtr containing the cached response headers. + */ +Http::ResponseHeaderMapPtr headersFromHeaderProto(const CacheFileHeader& header); + +/** + * Gets the trailers from a CacheFileTrailer message as an Envoy::Http::ResponseTrailerMapPtr. + * @param trailer the CacheFileTrailer message from which to extract the trailers. + * @return an Http::ResponseTrailerMapPtr containing the cached response trailers. + */ +Http::ResponseTrailerMapPtr trailersFromTrailerProto(const CacheFileTrailer& trailer); + +/** + * Gets the cache metadata from a CacheFileHeader message. + * @param header the CacheFileHeader message from which to extract the metadata. + * @return a ResponseMetadata object containing the cached metadata. + */ +ResponseMetadata metadataFromHeaderProto(const CacheFileHeader& header); + +/** + * Deserializes a CacheFileHeader message from a Buffer. + * @param buffer the buffer containing a serialized CacheFileHeader message. + * @return the deserialized CacheFileHeader message. + */ +CacheFileHeader makeCacheFileHeaderProto(Buffer::Instance& buffer); + +/** + * Deserializes a CacheFileTrailer message from a Buffer. + * @param buffer the buffer containing a serialized CacheFileTrailer message. + * @return the deserialized CacheFileTrailer message. + */ +CacheFileTrailer makeCacheFileTrailerProto(Buffer::Instance& buffer); + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.cc b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.cc new file mode 100644 index 0000000000000..ab7f37438fd6e --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.cc @@ -0,0 +1,41 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.h" + +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +using Common::AsyncFiles::AsyncFileHandle; + +CacheFileReader::CacheFileReader(AsyncFileHandle handle) : file_handle_(handle) {} + +void CacheFileReader::getBody(Event::Dispatcher& dispatcher, AdjustedByteRange range, + GetBodyCallback&& cb) { + auto queued = file_handle_->read( + &dispatcher, CacheFileFixedBlock::offsetToBody() + range.begin(), range.length(), + [len = range.length(), + cb = std::move(cb)](absl::StatusOr read_result) mutable -> void { + if (!read_result.ok()) { + return cb(nullptr, EndStream::Reset); + } + if (read_result.value()->length() != len) { + return cb(nullptr, EndStream::Reset); + } + return cb(std::move(read_result.value()), EndStream::More); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +CacheFileReader::~CacheFileReader() { + auto queued = file_handle_->close(nullptr, [](absl::Status) {}); + ASSERT(queued.ok()); +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.h b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.h new file mode 100644 index 0000000000000..b00967c77853a --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.h @@ -0,0 +1,27 @@ +#pragma once + +#include "source/extensions/common/async_files/async_file_handle.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +class CacheFileReader : public CacheReader { +public: + CacheFileReader(Common::AsyncFiles::AsyncFileHandle handle); + ~CacheFileReader() override; + // From CacheReader + void getBody(Event::Dispatcher& dispatcher, AdjustedByteRange range, GetBodyCallback&& cb) final; + +private: + Common::AsyncFiles::AsyncFileHandle file_handle_; +}; + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/config.cc b/source/extensions/http/cache_v2/file_system_http_cache/config.cc new file mode 100644 index 0000000000000..a7854d5c38458 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/config.cc @@ -0,0 +1,129 @@ +#include +#include + +#include "envoy/extensions/http/cache_v2/file_system_http_cache/v3/file_system_http_cache.pb.h" +#include "envoy/extensions/http/cache_v2/file_system_http_cache/v3/file_system_http_cache.pb.validate.h" +#include "envoy/registry/registry.h" + +#include "source/extensions/common/async_files/async_file_manager_factory.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { +namespace { + +/** + * Returns a copy of the original ConfigProto with a slash appended to cache_path + * if one was not present. + * @param original the original ConfigProto. + * @return the normalized ConfigProto. + */ +ConfigProto normalizeConfig(const ConfigProto& original) { + ConfigProto config = original; + if (!absl::EndsWith(config.cache_path(), "/") && !absl::EndsWith(config.cache_path(), "\\")) { + config.set_cache_path(absl::StrCat(config.cache_path(), "/")); + } + return config; +} + +/** + * A singleton that acts as a factory for generating and looking up FileSystemHttpCaches. + * When given equivalent configs, the singleton returns pointers to the same cache. + * When given different configs, the singleton returns different cache instances. + * If given configs with the same cache_path but different configuration, + * an error status is returned, as it doesn't make sense two operate two caches in the + * same path with different configurations. + */ +class CacheSingleton : public Envoy::Singleton::Instance { +public: + CacheSingleton( + std::shared_ptr&& async_file_manager_factory, + Thread::ThreadFactory& thread_factory) + : async_file_manager_factory_(async_file_manager_factory), + cache_eviction_thread_(thread_factory) {} + + absl::StatusOr> + get(std::shared_ptr singleton, const ConfigProto& non_normalized_config, + Server::Configuration::FactoryContext& context) { + std::shared_ptr cache; + ConfigProto config = normalizeConfig(non_normalized_config); + auto key = config.cache_path(); + absl::MutexLock lock(&mu_); + auto it = caches_.find(key); + if (it != caches_.end()) { + cache = it->second.lock(); + } + if (!cache) { + std::shared_ptr async_file_manager = + async_file_manager_factory_->getAsyncFileManager(config.manager_config()); + std::unique_ptr fs_cache = std::make_unique( + singleton, cache_eviction_thread_, std::move(config), std::move(async_file_manager), + context.scope()); + cache = CacheSessions::create(context, std::move(fs_cache)); + caches_[key] = cache; + } else { + // Check that the config of the cache found in the lookup table for the given path + // has the same config as the config being added. + FileSystemHttpCache& fs_cache = static_cast(cache->cache()); + if (!Protobuf::util::MessageDifferencer::Equals(fs_cache.config(), config)) { + return absl::InvalidArgumentError( + fmt::format("mismatched FileSystemHttpCacheV2Config with same path\n{}\nvs.\n{}", + fs_cache.config().DebugString(), config.DebugString())); + } + } + return cache; + } + +private: + std::shared_ptr async_file_manager_factory_; + CacheEvictionThread cache_eviction_thread_; + absl::Mutex mu_; + // We keep weak_ptr here so the caches can be destroyed if the config is updated to stop using + // that config of cache. The caches each keep shared_ptrs to this singleton, which keeps the + // singleton from being destroyed unless it's no longer keeping track of any caches. + // (The singleton shared_ptr is *only* held by cache instances.) + absl::flat_hash_map> caches_ ABSL_GUARDED_BY(mu_); +}; + +SINGLETON_MANAGER_REGISTRATION(file_system_http_cache_v2_singleton); + +class FileSystemHttpCacheFactory : public HttpCacheFactory { +public: + // From UntypedFactory + std::string name() const override { return std::string{FileSystemHttpCache::name()}; } + // From TypedFactory + ProtobufTypes::MessagePtr createEmptyConfigProto() override { + return std::make_unique(); + } + // From HttpCacheFactory + absl::StatusOr> + getCache(const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& filter_config, + Server::Configuration::FactoryContext& context) override { + ConfigProto config; + RETURN_IF_NOT_OK(MessageUtil::unpackTo(filter_config.typed_config(), config)); + std::shared_ptr caches = + context.serverFactoryContext().singletonManager().getTyped( + SINGLETON_MANAGER_REGISTERED_NAME(file_system_http_cache_v2_singleton), [&context] { + return std::make_shared( + Common::AsyncFiles::AsyncFileManagerFactory::singleton( + &context.serverFactoryContext().singletonManager()), + context.serverFactoryContext().api().threadFactory()); + }); + return caches->get(caches, config, context); + } +}; + +static Registry::RegisterFactory register_; + +} // namespace +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.cc b/source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.cc new file mode 100644 index 0000000000000..9ea82b71bc708 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.cc @@ -0,0 +1,311 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h" + +#include + +#include "source/common/filesystem/directory.h" +#include "source/common/http/header_map_impl.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/insert_context.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/lookup_context.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/stats.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +const CacheStats& FileSystemHttpCache::stats() const { return shared_->stats_; } +const ConfigProto& FileSystemHttpCache::config() const { return shared_->config_; } + +absl::string_view FileSystemHttpCache::name() { + return "envoy.extensions.http.cache_v2.file_system_http_cache"; +} + +FileSystemHttpCache::FileSystemHttpCache( + Singleton::InstanceSharedPtr owner, CacheEvictionThread& cache_eviction_thread, + ConfigProto config, std::shared_ptr&& async_file_manager, + Stats::Scope& stats_scope) + : owner_(owner), async_file_manager_(async_file_manager), + shared_(std::make_shared(config, stats_scope, cache_eviction_thread)), + cache_eviction_thread_(cache_eviction_thread), cache_info_(CacheInfo{name()}) { + cache_eviction_thread_.addCache(shared_); +} + +CacheShared::CacheShared(ConfigProto config, Stats::Scope& stats_scope, + CacheEvictionThread& eviction_thread) + : signal_eviction_([&eviction_thread]() { eviction_thread.signal(); }), config_(config), + stat_names_(stats_scope.symbolTable()), + stats_(generateStats(stat_names_, stats_scope, cachePath())) {} + +void CacheShared::disconnectEviction() { + absl::MutexLock lock(&signal_mu_); + signal_eviction_ = []() {}; +} + +FileSystemHttpCache::~FileSystemHttpCache() { + shared_->disconnectEviction(); + cache_eviction_thread_.removeCache(shared_); +} + +CacheInfo FileSystemHttpCache::cacheInfo() const { + CacheInfo info; + info.name_ = name(); + return info; +} + +void FileSystemHttpCache::lookup(LookupRequest&& lookup, LookupCallback&& callback) { + std::string filepath = absl::StrCat(cachePath(), generateFilename(lookup.key())); + async_file_manager_->openExistingFile( + &lookup.dispatcher(), filepath, Common::AsyncFiles::AsyncFileManager::Mode::ReadOnly, + [&dispatcher = lookup.dispatcher(), + callback = std::move(callback)](absl::StatusOr open_result) mutable { + if (!open_result.ok()) { + if (open_result.status().code() == absl::StatusCode::kNotFound) { + return callback(LookupResult{}); + } + ENVOY_LOG(error, "open file failed: {}", open_result.status()); + return callback(open_result.status()); + } + FileLookupContext::begin(dispatcher, std::move(open_result.value()), std::move(callback)); + }); +} + +void FileSystemHttpCache::insert(Event::Dispatcher& dispatcher, Key key, + Http::ResponseHeaderMapPtr headers, ResponseMetadata metadata, + HttpSourcePtr source, + std::shared_ptr progress) { + std::string filepath = absl::StrCat(cachePath(), generateFilename(key)); + FileInsertContext::begin(dispatcher, std::move(key), std::move(filepath), std::move(headers), + std::move(metadata), std::move(source), std::move(progress), shared_, + *async_file_manager_); +} + +// Helper class to reduce the lambda depth of updateHeaders. +class HeaderUpdateContext : public Logger::Loggable { +public: + static void begin(Event::Dispatcher& dispatcher, AsyncFileHandle handle, + Buffer::InstancePtr new_headers) { + auto p = new HeaderUpdateContext(dispatcher, std::move(handle), std::move(new_headers)); + p->readHeaderBlock(); + } + +private: + HeaderUpdateContext(Event::Dispatcher& dispatcher, AsyncFileHandle handle, + Buffer::InstancePtr new_headers) + : dispatcher_(dispatcher), handle_(std::move(handle)), new_headers_(std::move(new_headers)) {} + + void readHeaderBlock() { + auto queued = handle_->read( + &dispatcher_, 0, CacheFileFixedBlock::size(), + [this](absl::StatusOr read_result) { + if (!read_result.ok()) { + return fail("failed to read header block", read_result.status()); + } else if (read_result.value()->length() != CacheFileFixedBlock::size()) { + return fail( + "incomplete read of header block", + absl::AbortedError(absl::StrCat("read ", read_result.value()->length(), + ", expected ", CacheFileFixedBlock::size()))); + } + header_block_.populateFromStringView(read_result.value()->toString()); + truncateOldHeaders(); + }); + ASSERT(queued.ok()); + } + + void truncateOldHeaders() { + auto queued = handle_->truncate(&dispatcher_, header_block_.offsetToHeaders(), + [this](absl::Status truncate_result) { + if (!truncate_result.ok()) { + return fail("failed to truncate headers", truncate_result); + } + overwriteHeaderBlock(); + }); + ASSERT(queued.ok()); + } + + void overwriteHeaderBlock() { + size_t len = new_headers_->length(); + header_block_.setHeadersSize(len); + Buffer::OwnedImpl write_buf; + header_block_.serializeToBuffer(write_buf); + auto queued = + handle_->write(&dispatcher_, write_buf, 0, [this](absl::StatusOr write_result) { + if (!write_result.ok()) { + return fail("overwriting headers failed", write_result.status()); + } else if (write_result.value() != CacheFileFixedBlock::size()) { + return fail( + "overwriting headers failed", + absl::AbortedError(absl::StrCat("wrote ", write_result.value(), ", expected ", + CacheFileFixedBlock::size()))); + } + writeNewHeaders(); + }); + ASSERT(queued.ok()); + } + + void writeNewHeaders() { + size_t len = new_headers_->length(); + auto queued = + handle_->write(&dispatcher_, *new_headers_, header_block_.offsetToHeaders(), + [this, len](absl::StatusOr write_result) { + if (!write_result.ok()) { + return fail("failed to write new headers", write_result.status()); + } else if (write_result.value() != len) { + return fail("incomplete write of new headers", + absl::AbortedError(absl::StrCat( + "wrote ", write_result.value(), ", expected ", len))); + } + finish(); + }); + ASSERT(queued.ok()); + } + + void fail(absl::string_view msg, absl::Status status) { + ENVOY_LOG(error, "{}: {}", msg, status); + finish(); + } + + void finish() { + auto close_status = handle_->close(nullptr, [](absl::Status) {}); + ASSERT(close_status.ok()); + delete this; + } + + Event::Dispatcher& dispatcher_; + AsyncFileHandle handle_; + Buffer::InstancePtr new_headers_; + CacheFileFixedBlock header_block_; +}; + +/** + * Replaces the headers of a cache entry. + * + * In order to avoid a race in which the wrong size of headers is read by + * one instance while headers are being updated by another instance, the + * update is performed by: + * 1. truncate the file so there are no headers. + * 2. update the size of the headers in the header block. + * 3. write the new headers. + * + * This way, if another instance tries to read headers when they are briefly + * not present, that read will fail to get the expected size, and it will be + * treated as a cache miss rather than providing a "mixed" (corrupted) read. + * + * Most of the time the cache is not reading headers from the file as they + * are cached in memory, so even this race should be extremely rare. + */ +void FileSystemHttpCache::updateHeaders(Event::Dispatcher& dispatcher, const Key& key, + const Http::ResponseHeaderMap& updated_headers, + const ResponseMetadata& updated_metadata) { + std::string filepath = absl::StrCat(cachePath(), generateFilename(key)); + CacheFileHeader header_proto = makeCacheFileHeaderProto(key, updated_headers, updated_metadata); + Buffer::InstancePtr header_buffer = std::make_unique(); + Buffer::OwnedImpl tmp = bufferFromProto(header_proto); + header_buffer->move(tmp); + async_file_manager_->openExistingFile( + &dispatcher, filepath, Common::AsyncFiles::AsyncFileManager::Mode::ReadWrite, + [&dispatcher = dispatcher, header_buffer = std::move(header_buffer)]( + absl::StatusOr open_result) mutable { + if (!open_result.ok()) { + ENVOY_LOG(error, "open file for updateHeaders failed: {}", open_result.status()); + return; + } + HeaderUpdateContext::begin(dispatcher, open_result.value(), std::move(header_buffer)); + }); +} + +void FileSystemHttpCache::evict(Event::Dispatcher& dispatcher, const Key& key) { + std::string filepath = absl::StrCat(cachePath(), generateFilename(key)); + async_file_manager_->stat(&dispatcher, filepath, + [file_manager = async_file_manager_, &dispatcher, filepath, + stats = shared_](absl::StatusOr stat_result) { + if (!stat_result.ok()) { + return; + } + off_t sz = stat_result.value().st_size; + file_manager->unlink(&dispatcher, filepath, + [sz, stats](absl::Status unlink_result) { + if (!unlink_result.ok()) { + return; + } + stats->trackFileRemoved(sz); + }); + }); +} + +void FileSystemHttpCache::touch(const Key&, SystemTime) { + // Reading from a file counts as a touch for stat purposes, so no + // need to update timestamps directly. +} + +absl::string_view FileSystemHttpCache::cachePath() const { return shared_->cachePath(); } + +std::string FileSystemHttpCache::generateFilename(const Key& key) const { + // TODO(ravenblack): Add support for directory tree structure. + return absl::StrCat("cache-", stableHashKey(key)); +} + +void FileSystemHttpCache::trackFileAdded(uint64_t file_size) { shared_->trackFileAdded(file_size); } +void CacheShared::trackFileAdded(uint64_t file_size) { + size_count_++; + size_bytes_ += file_size; + stats_.size_count_.inc(); + stats_.size_bytes_.add(file_size); + if (needsEviction()) { + { + absl::MutexLock lock(&signal_mu_); + signal_eviction_(); + } + } +} + +void FileSystemHttpCache::trackFileRemoved(uint64_t file_size) { + shared_->trackFileRemoved(file_size); +} + +void CacheShared::trackFileRemoved(uint64_t file_size) { + // Atomically decrement-but-clamp-at-zero the count of files in the cache. + // + // It is an error to try to set a gauge to less than zero, so we must actively + // prevent that underflow. + // + // See comment on size_bytes and size_count in stats.h for explanation of how stat + // values can be out of sync with the actionable cache. + uint64_t count, size; + do { + count = size_count_; + } while (count > 0 && !size_count_.compare_exchange_weak(count, count - 1)); + + stats_.size_count_.set(size_count_); + // Atomically decrease-but-clamp-at-zero the size of files in the cache, by file_size. + // + // See comment above for why; the same rationale applies here. + do { + size = size_bytes_; + } while (size >= file_size && !size_bytes_.compare_exchange_weak(size, size - file_size)); + + if (size < file_size) { + size_bytes_ = 0; + } + stats_.size_bytes_.set(size_bytes_); +} + +bool CacheShared::needsEviction() const { + if (config_.has_max_cache_size_bytes() && size_bytes_ > config_.max_cache_size_bytes().value()) { + return true; + } + if (config_.has_max_cache_entry_count() && + size_count_ > config_.max_cache_entry_count().value()) { + return true; + } + return false; +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h b/source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h new file mode 100644 index 0000000000000..c2a19ce8c630b --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h @@ -0,0 +1,189 @@ +#pragma once + +#include + +#include "envoy/extensions/http/cache_v2/file_system_http_cache/v3/file_system_http_cache.pb.h" + +#include "source/common/common/logger.h" +#include "source/extensions/common/async_files/async_file_manager.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/stats.h" + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" +#include "absl/synchronization/mutex.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +using ConfigProto = + envoy::extensions::http::cache_v2::file_system_http_cache::v3::FileSystemHttpCacheV2Config; + +class CacheEvictionThread; +struct CacheShared; + +/** + * An instance of a cache. There may be multiple caches in a single envoy configuration. + * Caches are jointly owned by filters using the cache and the filter configurations. + * When the filter configurations are destroyed and all cache actions from those filters + * are resolved, the cache instance is destroyed. + * Cache instances jointly own the CacheSingleton. + * If all cache instances are destroyed, the CacheSingleton is destroyed. + * + * See DESIGN.md for details of cache behavior. + */ +class FileSystemHttpCache : public HttpCache, + public std::enable_shared_from_this, + public Logger::Loggable { +public: + FileSystemHttpCache(Singleton::InstanceSharedPtr owner, + CacheEvictionThread& cache_eviction_thread, ConfigProto config, + std::shared_ptr&& async_file_manager, + Stats::Scope& stats_scope); + ~FileSystemHttpCache() override; + + // Overrides for HttpCache + CacheInfo cacheInfo() const override; + void lookup(LookupRequest&& lookup, LookupCallback&& callback) override; + void evict(Event::Dispatcher& dispatcher, const Key& key) override; + void touch(const Key& key, SystemTime timestamp) override; + void updateHeaders(Event::Dispatcher& dispatcher, const Key& key, + const Http::ResponseHeaderMap& updated_headers, + const ResponseMetadata& updated_metadata) override; + void insert(Event::Dispatcher& dispatcher, Key key, Http::ResponseHeaderMapPtr headers, + ResponseMetadata metadata, HttpSourcePtr source, + std::shared_ptr progress) override; + + const CacheStats& stats() const; + + /** + * The config of this cache. Used by the factory to ensure there aren't incompatible + * configs using the same path. + * @return the config of this cache. + */ + const ConfigProto& config() const; + + /** + * Returns the extension name. + * @return the extension name. + */ + static absl::string_view name(); + + /** + * Returns a filename for the cache entry with the given key. + * @param key the key for which to generate a filename. + * @return a filename for that cache entry (path not included). + */ + std::string generateFilename(const Key& key) const; + + /** + * Returns the path for this cache instance. Guaranteed to end in a path-separator. + * @return the configured path for this cache instance. + */ + absl::string_view cachePath() const; + + /** + * Updates stats to reflect that a file has been added to the cache. + * @param file_size The size in bytes of the file that was added. + */ + void trackFileAdded(uint64_t file_size); + + /** + * Updates stats to reflect that a file has been removed from the cache. + * @param file_size The size in bytes of the file that was removed. + */ + void trackFileRemoved(uint64_t file_size); + + // Waits for all queued actions to be completed. + inline void drainAsyncFileActionsForTest() { async_file_manager_->waitForIdle(); }; + +private: + // A shared_ptr to keep the cache singleton alive as long as any of its caches are in use. + const Singleton::InstanceSharedPtr owner_; + + std::shared_ptr async_file_manager_; + + // Stats and config are held in a shared_ptr so that CacheEvictionThread can use + // them even if the cache instance has been deleted while it performed work. + std::shared_ptr shared_; + + // This reference must be declared after owner_, since it can potentially be + // invalid after owner_ is destroyed. + CacheEvictionThread& cache_eviction_thread_; + + // Allow test access to cache_eviction_thread_ for synchronization. + friend class FileSystemCacheTestContext; + + CacheInfo cache_info_; +}; + +// This part of the cache implementation is shared between CacheEvictionThread and +// FileSystemHttpCache. The implementation of CacheShared is also split between the +// two implementation files, accordingly. +struct CacheShared { + CacheShared(ConfigProto config, Stats::Scope& stats_scope, CacheEvictionThread& eviction_thread); + absl::Mutex signal_mu_; + std::function signal_eviction_ ABSL_GUARDED_BY(signal_mu_); + const ConfigProto config_; + CacheStatNames stat_names_; + CacheStats stats_; + // These are part of stats, but we have to track them separately because there is + // potential to go "less than zero" due to not having sole control of the file cache; + // gauge values don't have fine enough control to prevent that, and aren't allowed to + // be negative. + // + // See comment on size_bytes and size_count in stats.h for explanation of how stat + // values can be out of sync with the actionable cache. + std::atomic size_count_ = 0; + std::atomic size_bytes_ = 0; + bool needs_init_ = true; + + /** + * When the cache is deleted, cache state metrics may still be being updated - the + * cache eviction thread may or may not outlive that, so updates to cache state + * must be prevented from triggering eviction beyond that deletion. + */ + void disconnectEviction(); + + /** + * @return true if the eviction thread should do a pass over this cache. + */ + bool needsEviction() const; + + /** + * Returns the path for this cache instance. Guaranteed to end in a path-separator. + * @return the configured path for this cache instance. + */ + absl::string_view cachePath() const { return config_.cache_path(); } + + /** + * Updates stats (size and count) to reflect that a file has been added to the cache. + * @param file_size The size in bytes of the file that was added. + */ + void trackFileAdded(uint64_t file_size); + + /** + * Updates stats (size and count) to reflect that a file has been removed from the cache. + * @param file_size The size in bytes of the file that was removed. + */ + void trackFileRemoved(uint64_t file_size); + + /** + * Performs an eviction pass over this cache. Runs in the CacheEvictionThread. + */ + void evict(); + + /** + * Initializes the stats for this cache. Runs in the CacheEvictionThread. + */ + void initStats(); +}; + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/insert_context.cc b/source/extensions/http/cache_v2/file_system_http_cache/insert_context.cc new file mode 100644 index 0000000000000..1d54bf8270245 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/insert_context.cc @@ -0,0 +1,246 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/insert_context.h" + +#include "source/common/protobuf/utility.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/lookup_context.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +// Arbitrary 128K fragments to balance memory usage and speed. +static constexpr size_t MaxInsertFragmentSize = 128 * 1024; + +using Common::AsyncFiles::AsyncFileHandle; +using Common::AsyncFiles::AsyncFileManager; + +void FileInsertContext::begin(Event::Dispatcher& dispatcher, Key key, std::string filepath, + Http::ResponseHeaderMapPtr headers, ResponseMetadata metadata, + HttpSourcePtr source, std::shared_ptr progress, + std::shared_ptr stat_recorder, + AsyncFileManager& file_manager) { + auto p = new FileInsertContext(dispatcher, std::move(key), std::move(filepath), + std::move(headers), std::move(metadata), std::move(source), + std::move(progress), std::move(stat_recorder)); + p->createFile(file_manager); +} + +FileInsertContext::FileInsertContext(Event::Dispatcher& dispatcher, Key key, std::string filepath, + Http::ResponseHeaderMapPtr headers, ResponseMetadata metadata, + HttpSourcePtr source, + std::shared_ptr progress, + std::shared_ptr stat_recorder) + : dispatcher_(dispatcher), filepath_(std::move(filepath)), + cache_file_header_proto_(makeCacheFileHeaderProto(key, *headers, metadata)), + headers_(std::move(headers)), source_(std::move(source)), + progress_receiver_(std::move(progress)), stat_recorder_(std::move(stat_recorder)) {} + +void FileInsertContext::fail(absl::Status status) { + progress_receiver_->onInsertFailed(status); + if (file_handle_) { + auto queued = file_handle_->close(nullptr, [](absl::Status) {}); + ASSERT(queued.ok()); + } + delete this; +} + +void FileInsertContext::complete() { + auto queued = file_handle_->close(nullptr, [](absl::Status) {}); + ASSERT(queued.ok()); + delete this; +} + +void FileInsertContext::createFile(AsyncFileManager& file_manager) { + absl::string_view cache_path = absl::string_view{filepath_}; + cache_path = absl::string_view{cache_path.begin(), cache_path.rfind('/') + 1}; + file_manager.createAnonymousFile( + &dispatcher_, cache_path, [this](absl::StatusOr open_result) -> void { + if (!open_result.ok()) { + return fail( + absl::Status(open_result.status().code(), + fmt::format("create file failed: {}", open_result.status().message()))); + } + file_handle_ = std::move(open_result.value()); + dupFile(); + }); +} + +void FileInsertContext::dupFile() { + auto queued = + file_handle_->duplicate(&dispatcher_, [this](absl::StatusOr dup_result) { + if (!dup_result.ok()) { + return fail( + absl::Status(dup_result.status().code(), fmt::format("duplicate file failed: {}", + dup_result.status().message()))); + } + bool end_stream = source_ == nullptr; + progress_receiver_->onHeadersInserted( + std::make_unique(std::move(dup_result.value())), std::move(headers_), + end_stream); + writeEmptyHeaderBlock(); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileInsertContext::writeEmptyHeaderBlock() { + Buffer::OwnedImpl unset_header; + header_block_.serializeToBuffer(unset_header); + // Write an empty header block. + auto queued = file_handle_->write( + &dispatcher_, unset_header, 0, [this](absl::StatusOr write_result) { + if (!write_result.ok()) { + return fail(absl::Status( + write_result.status().code(), + fmt::format("write to file failed: {}", write_result.status().message()))); + } else if (write_result.value() != CacheFileFixedBlock::size()) { + return fail(absl::UnavailableError( + fmt::format("write to file failed; wrote {} bytes instead of {}", + write_result.value(), CacheFileFixedBlock::size()))); + } + if (source_) { + getBody(); + } else { + writeHeaders(); + } + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileInsertContext::getBody() { + ASSERT(source_); + source_->getBody(AdjustedByteRange(read_pos_, read_pos_ + MaxInsertFragmentSize), + [this](Buffer::InstancePtr buf, EndStream end_stream) { + if (end_stream == EndStream::Reset) { + return fail( + absl::UnavailableError("cache write failed due to upstream reset")); + } + if (buf == nullptr) { + if (end_stream == EndStream::End) { + progress_receiver_->onBodyInserted(AdjustedByteRange(0, read_pos_), true); + writeHeaders(); + } else { + getTrailers(); + } + } else { + read_pos_ += buf->length(); + onBody(std::move(buf), end_stream == EndStream::End); + } + }); +} + +void FileInsertContext::onBody(Buffer::InstancePtr buf, bool end_stream) { + ASSERT(buf); + size_t len = buf->length(); + auto queued = file_handle_->write( + &dispatcher_, *buf, header_block_.offsetToBody() + header_block_.bodySize(), + [this, len, end_stream](absl::StatusOr write_result) { + if (!write_result.ok()) { + return fail(absl::Status( + write_result.status().code(), + fmt::format("write to file failed: {}", write_result.status().message()))); + } else if (write_result.value() != len) { + return fail(absl::UnavailableError(fmt::format( + "write to file failed: wrote {} bytes instead of {}", write_result.value(), len))); + } + progress_receiver_->onBodyInserted( + AdjustedByteRange(header_block_.bodySize(), header_block_.bodySize() + len), + end_stream); + header_block_.setBodySize(header_block_.bodySize() + len); + if (end_stream) { + writeHeaders(); + } else { + getBody(); + } + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileInsertContext::getTrailers() { + source_->getTrailers([this](Http::ResponseTrailerMapPtr trailers, EndStream end_stream) { + if (end_stream == EndStream::Reset) { + return fail( + absl::UnavailableError("write to cache failed, upstream reset during getTrailers")); + } + onTrailers(std::move(trailers)); + }); +} + +void FileInsertContext::onTrailers(Http::ResponseTrailerMapPtr trailers) { + CacheFileTrailer trailer_proto = makeCacheFileTrailerProto(*trailers); + progress_receiver_->onTrailersInserted(std::move(trailers)); + Buffer::OwnedImpl trailer_buffer = bufferFromProto(trailer_proto); + header_block_.setTrailersSize(trailer_buffer.length()); + auto queued = file_handle_->write(&dispatcher_, trailer_buffer, header_block_.offsetToTrailers(), + [this](absl::StatusOr write_result) { + if (!write_result.ok() || + write_result.value() != header_block_.trailerSize()) { + // We've already told the client that the write worked, and + // it already has the data they need, so we can act like it + // was complete until the next lookup, even though the file + // didn't actually get linked. + return complete(); + } + writeHeaders(); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileInsertContext::writeHeaders() { + Buffer::OwnedImpl header_buffer = bufferFromProto(cache_file_header_proto_); + header_block_.setHeadersSize(header_buffer.length()); + auto queued = file_handle_->write(&dispatcher_, header_buffer, header_block_.offsetToHeaders(), + [this](absl::StatusOr write_result) { + if (!write_result.ok() || + write_result.value() != header_block_.headerSize()) { + // We've already told the client that the write worked, and + // it already has the data they need, so we can act like it + // was complete until the next lookup, even though the file + // didn't actually get linked. + return complete(); + } + commit(); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileInsertContext::commit() { + // now that the header block knows the size of all the pieces, overwrite it in the file. + Buffer::OwnedImpl block_buffer; + header_block_.serializeToBuffer(block_buffer); + auto queued = file_handle_->write( + &dispatcher_, block_buffer, 0, [this](absl::StatusOr write_result) { + if (!write_result.ok() || write_result.value() != CacheFileFixedBlock::size()) { + // We've already told the client that the write worked, and it already + // has the data they need, so we can act like it was complete until + // the next lookup, even though the file didn't actually get linked. + return complete(); + } + createHardLink(); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileInsertContext::createHardLink() { + auto queued = + file_handle_->createHardLink(&dispatcher_, filepath_, [this](absl::Status link_result) { + if (!link_result.ok()) { + ENVOY_LOG(error, "failed to link file {}: {}", filepath_, link_result); + return complete(); + } + ENVOY_LOG(debug, "created cache file {}", filepath_); + uint64_t file_size = header_block_.offsetToTrailers() + header_block_.trailerSize(); + stat_recorder_->trackFileAdded(file_size); + complete(); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/insert_context.h b/source/extensions/http/cache_v2/file_system_http_cache/insert_context.h new file mode 100644 index 0000000000000..0eda265dfae7c --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/insert_context.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include "source/extensions/common/async_files/async_file_manager.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.pb.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +struct CacheShared; + +class FileInsertContext : public Logger::Loggable { +public: + static void begin(Event::Dispatcher& dispatcher, Key key, std::string filepath, + Http::ResponseHeaderMapPtr headers, ResponseMetadata metadata, + HttpSourcePtr source, std::shared_ptr progress, + std::shared_ptr stat_recorder, + Common::AsyncFiles::AsyncFileManager& async_file_manager); + +private: + FileInsertContext(Event::Dispatcher& dispatcher, Key key, std::string filepath, + Http::ResponseHeaderMapPtr headers, ResponseMetadata metadata, + HttpSourcePtr source, std::shared_ptr progress, + std::shared_ptr stat_recorder); + void fail(absl::Status status); + void complete(); + + // The sequence of actions involved in writing the cache entry to a file. Each + // of these actions are posted to an async file thread, and the results posted back + // to the dispatcher, so the callbacks are run on the original filter's thread. + // Any failure calls CacheProgressReceiver::onInsertFailed. + + // The first step of writing the cache entry to a file. On success calls + // dupFile. + void createFile(Common::AsyncFiles::AsyncFileManager& file_manager); + // Makes a duplicate file handle for the Reader. + // On success calls writeEmptyHeaderBlock and CacheProgressReceiver::onHeadersInserted. + void dupFile(); + // An empty header block is written at the start of the file, making room for + // a populated header block to be written later. On success calls + // either getBody or writeHeaders depending on if there is any body. + void writeEmptyHeaderBlock(); + // Reads a chunk of body for insertion. Calls onBody on success. Calls getTrailers + // if no body remained and there are trailers, or writeHeaders if no body remained + // and there are no trailers. + void getBody(); + // Writes a chunk of body to the file. Calls CacheProgressReceiver::onBodyInserted + // and getBody, or writeHeaders if body ended and there are no trailers. + void onBody(Buffer::InstancePtr buf, bool end_stream); + // Reads trailers. Calls onTrailers on success. + void getTrailers(); + // Writes the trailers to file. Calls CacheProcessReceiver::onTrailersInserted + // and writeHeaders on success. + void onTrailers(Http::ResponseTrailerMapPtr trailers); + // Writes the headers to file. Calls commit on success. + void writeHeaders(); + // Rewrites the header block of the file, and calls createHardLink. + void commit(); + // Creates a hard link, and updates stats. + void createHardLink(); + + Event::Dispatcher& dispatcher_; + std::string filepath_; + CacheFileHeader cache_file_header_proto_; + Http::ResponseHeaderMapPtr headers_; + HttpSourcePtr source_; + std::shared_ptr progress_receiver_; + std::shared_ptr stat_recorder_; + CacheFileFixedBlock header_block_; + Common::AsyncFiles::AsyncFileHandle file_handle_; + off_t read_pos_{0}; +}; + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/lookup_context.cc b/source/extensions/http/cache_v2/file_system_http_cache/lookup_context.cc new file mode 100644 index 0000000000000..d938a80f6b8d4 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/lookup_context.cc @@ -0,0 +1,104 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/lookup_context.h" + +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.pb.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_reader.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +FileLookupContext::FileLookupContext(Event::Dispatcher& dispatcher, AsyncFileHandle handle, + HttpCache::LookupCallback&& callback) + : dispatcher_(dispatcher), file_handle_(std::move(handle)), callback_(std::move(callback)) {} + +void FileLookupContext::begin(Event::Dispatcher& dispatcher, AsyncFileHandle handle, + HttpCache::LookupCallback&& callback) { + // bare pointer because this object owns itself - it gets captured in + // lambdas and is deleted when 'done' is eventually called. + FileLookupContext* p = new FileLookupContext(dispatcher, std::move(handle), std::move(callback)); + p->getHeaderBlock(); +} + +void FileLookupContext::done(absl::StatusOr&& result) { + if (!result.ok() || result.value().cache_reader_ == nullptr) { + auto queued = file_handle_->close(nullptr, [](absl::Status) {}); + ASSERT(queued.ok(), queued.status().ToString()); + } + auto cb = std::move(callback_); + delete this; + cb(std::move(result)); +} + +absl::Status cacheEntryInvalidStatus() { return absl::DataLossError("corrupted cache file"); } + +void FileLookupContext::getHeaderBlock() { + auto queued = + file_handle_->read(&dispatcher_, 0, CacheFileFixedBlock::size(), + [this](absl::StatusOr read_result) -> void { + if (!read_result.ok()) { + return done(read_result.status()); + } + if (read_result.value()->length() != CacheFileFixedBlock::size()) { + return done(cacheEntryInvalidStatus()); + } + header_block_.populateFromStringView(read_result.value()->toString()); + if (!header_block_.isValid()) { + return done(cacheEntryInvalidStatus()); + } + if (header_block_.trailerSize()) { + getTrailers(); + } else { + getHeaders(); + } + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileLookupContext::getHeaders() { + auto queued = + file_handle_->read(&dispatcher_, header_block_.offsetToHeaders(), header_block_.headerSize(), + [this](absl::StatusOr read_result) -> void { + if (!read_result.ok()) { + return done(read_result.status()); + } + if (read_result.value()->length() != header_block_.headerSize()) { + return done(cacheEntryInvalidStatus()); + } + auto header_proto = makeCacheFileHeaderProto(*read_result.value()); + result_.response_headers_ = headersFromHeaderProto(header_proto); + result_.response_metadata_ = metadataFromHeaderProto(header_proto); + result_.body_length_ = header_block_.bodySize(); + result_.cache_reader_ = + std::make_unique(std::move(file_handle_)); + return done(std::move(result_)); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +void FileLookupContext::getTrailers() { + auto queued = file_handle_->read( + &dispatcher_, header_block_.offsetToTrailers(), header_block_.trailerSize(), + [this](absl::StatusOr read_result) -> void { + if (!read_result.ok()) { + return done(read_result.status()); + } + if (read_result.value()->length() != header_block_.trailerSize()) { + return done(cacheEntryInvalidStatus()); + } + auto trailer_proto = makeCacheFileTrailerProto(*read_result.value()); + result_.response_trailers_ = trailersFromTrailerProto(trailer_proto); + getHeaders(); + }); + ASSERT(queued.ok(), queued.status().ToString()); +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/lookup_context.h b/source/extensions/http/cache_v2/file_system_http_cache/lookup_context.h new file mode 100644 index 0000000000000..2fec1d7c302bf --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/lookup_context.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include "source/extensions/common/async_files/async_file_handle.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +class CacheSession; +class FileSystemHttpCache; + +using Envoy::Extensions::Common::AsyncFiles::AsyncFileHandle; + +class FileLookupContext { +public: + static void begin(Event::Dispatcher& dispatcher, AsyncFileHandle handle, + HttpCache::LookupCallback&& callback); + +private: + FileLookupContext(Event::Dispatcher& dispatcher, AsyncFileHandle handle, + HttpCache::LookupCallback&& callback); + void getHeaderBlock(); + void getHeaders(); + void getTrailers(); + void done(absl::StatusOr&& result); + + Event::Dispatcher& dispatcher_; + AsyncFileHandle file_handle_; + CacheFileFixedBlock header_block_; + HttpCache::LookupCallback callback_; + LookupResult result_; +}; + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/stats.cc b/source/extensions/http/cache_v2/file_system_http_cache/stats.cc new file mode 100644 index 0000000000000..2d3cbae5b67c8 --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/stats.cc @@ -0,0 +1,22 @@ +#include "source/extensions/http/cache_v2/file_system_http_cache/stats.h" + +#include "absl/strings/str_replace.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +CacheStats generateStats(CacheStatNames& stat_names, Stats::Scope& scope, + absl::string_view cache_path) { + Stats::StatName cache_path_statname = + stat_names.pool_.add(absl::StrReplaceAll(cache_path, {{".", "_"}})); + return {stat_names, scope, cache_path_statname}; +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/file_system_http_cache/stats.h b/source/extensions/http/cache_v2/file_system_http_cache/stats.h new file mode 100644 index 0000000000000..cff010a106d0e --- /dev/null +++ b/source/extensions/http/cache_v2/file_system_http_cache/stats.h @@ -0,0 +1,72 @@ +#pragma once + +#include "envoy/stats/stats_macros.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +/** + * All cache stats. @see stats_macros.h + * + * Note that size_bytes and size_count may drift away from true values, due to: + * - Changes to the filesystem may be made outside of the process, which will not be + * accounted for. (Including, during hot restart, overlapping envoy processes.) + * - Files completed while pre-cache-purge measurement is in progress may not be counted. + * - Changes in file size due to header updates are assumed to be negligible, and are ignored. + * + * Drift will eventually be reconciled at the next pre-cache-purge measurement. + * + * There are also cache_hit_ and cache_miss_, defined separately to accommodate extra tags; + * these two both go into the stat with key `event`, and with tag `event_type=(hit|miss)` + **/ + +#define ALL_CACHE_STATS(COUNTER, GAUGE, HISTOGRAM, TEXT_READOUT, STATNAME) \ + COUNTER(eviction_runs) \ + GAUGE(size_bytes, NeverImport) \ + GAUGE(size_count, NeverImport) \ + GAUGE(size_limit_bytes, NeverImport) \ + GAUGE(size_limit_count, NeverImport) \ + STATNAME(cache) \ + STATNAME(cache_path) +// TODO(ravenblack): Add other stats from DESIGN.md + +#define COUNTER_HELPER_(NAME) \ + , NAME##_( \ + Envoy::Stats::Utility::counterFromStatNames(scope, {prefix_, stat_names.NAME##_}, tags_)) +#define GAUGE_HELPER_(NAME, MODE) \ + , NAME##_(Envoy::Stats::Utility::gaugeFromStatNames( \ + scope, {prefix_, stat_names.NAME##_}, Envoy::Stats::Gauge::ImportMode::MODE, tags_)) +#define STATNAME_HELPER_(NAME) + +MAKE_STAT_NAMES_STRUCT(CacheStatNames, ALL_CACHE_STATS); + +struct CacheStats { + CacheStats(const CacheStatNames& stat_names, Envoy::Stats::Scope& scope, + Stats::StatName cache_path) + : stat_names_(stat_names), prefix_(stat_names_.cache_), cache_path_(cache_path), + tags_({{stat_names_.cache_path_, cache_path_}}) + ALL_CACHE_STATS(COUNTER_HELPER_, GAUGE_HELPER_, HISTOGRAM_HELPER_, TEXT_READOUT_HELPER_, + STATNAME_HELPER_) {} + +private: + const CacheStatNames& stat_names_; + const Stats::StatName prefix_; + const Stats::StatName cache_path_; + Stats::StatNameTagVector tags_; + +public: + ALL_CACHE_STATS(GENERATE_COUNTER_STRUCT, GENERATE_GAUGE_STRUCT, GENERATE_HISTOGRAM_STRUCT, + GENERATE_TEXT_READOUT_STRUCT, GENERATE_STATNAME_STRUCT); +}; + +CacheStats generateStats(CacheStatNames& stat_names, Stats::Scope& scope, + absl::string_view cache_path); + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/simple_http_cache/BUILD b/source/extensions/http/cache_v2/simple_http_cache/BUILD new file mode 100644 index 0000000000000..ff08685c6aaae --- /dev/null +++ b/source/extensions/http/cache_v2/simple_http_cache/BUILD @@ -0,0 +1,29 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +## WIP: Simple in-memory cache storage plugin. Not ready for deployment. + +envoy_extension_package() + +envoy_cc_extension( + name = "config", + srcs = ["simple_http_cache.cc"], + hdrs = ["simple_http_cache.h"], + deps = [ + "//envoy/registry", + "//envoy/runtime:runtime_interface", + "//source/common/buffer:buffer_lib", + "//source/common/common:macros", + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf", + "//source/extensions/filters/http/cache_v2:cache_sessions_impl_lib", + "//source/extensions/filters/http/cache_v2:http_cache_lib", + "@envoy_api//envoy/extensions/http/cache_v2/simple_http_cache/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.cc b/source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.cc new file mode 100644 index 0000000000000..e2d55243ca506 --- /dev/null +++ b/source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.cc @@ -0,0 +1,248 @@ +#include "source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.h" + +#include "envoy/extensions/http/cache_v2/simple_http_cache/v3/config.pb.h" +#include "envoy/registry/registry.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/http/header_map_impl.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +constexpr absl::string_view Name = "envoy.extensions.http.cache_v2.simple"; + +constexpr uint64_t InsertReadChunkSize = 512 * 1024; + +class InsertContext { +public: + static void start(std::shared_ptr entry, + std::shared_ptr progress_receiver, HttpSourcePtr source); + +private: + InsertContext(std::shared_ptr entry, + std::shared_ptr progress_receiver, HttpSourcePtr source); + void onBody(AdjustedByteRange range, Buffer::InstancePtr buffer, EndStream end_stream); + void onTrailers(Http::ResponseTrailerMapPtr trailers, EndStream end_stream); + std::shared_ptr entry_; + std::shared_ptr progress_receiver_; + HttpSourcePtr source_; +}; + +class SimpleHttpCacheReader : public CacheReader { +public: + SimpleHttpCacheReader(std::shared_ptr entry) : entry_(std::move(entry)) {} + void getBody(Event::Dispatcher& dispatcher, AdjustedByteRange range, + GetBodyCallback&& cb) override; + +private: + std::shared_ptr entry_; +}; + +void SimpleHttpCacheReader::getBody(Event::Dispatcher&, AdjustedByteRange range, + GetBodyCallback&& cb) { + cb(entry_->body(std::move(range)), EndStream::More); +} + +void InsertContext::start(std::shared_ptr entry, + std::shared_ptr progress_receiver, + HttpSourcePtr source) { + auto ctx = new InsertContext(std::move(entry), std::move(progress_receiver), std::move(source)); + ctx->source_->getBody(AdjustedByteRange(0, InsertReadChunkSize), [ctx](Buffer::InstancePtr buffer, + EndStream end_stream) { + ctx->onBody(AdjustedByteRange(0, InsertReadChunkSize), std::move(buffer), end_stream); + }); +} + +InsertContext::InsertContext(std::shared_ptr entry, + std::shared_ptr progress_receiver, + HttpSourcePtr source) + : entry_(std::move(entry)), progress_receiver_(std::move(progress_receiver)), + source_(std::move(source)) {} + +void InsertContext::onBody(AdjustedByteRange range, Buffer::InstancePtr buffer, + EndStream end_stream) { + if (end_stream == EndStream::Reset) { + progress_receiver_->onInsertFailed(absl::UnavailableError("upstream reset")); + delete this; + return; + } + if (end_stream == EndStream::End) { + entry_->setEndStreamAfterBody(); + } + if (buffer) { + ASSERT(range.length() >= buffer->length()); + range = AdjustedByteRange(range.begin(), range.begin() + buffer->length()); + entry_->appendBody(std::move(buffer)); + } else if (end_stream == EndStream::More) { + // Neither buffer nor EndStream::End means we want trailers. + return source_->getTrailers([this](Http::ResponseTrailerMapPtr trailers, EndStream end_stream) { + onTrailers(std::move(trailers), end_stream); + }); + } else { + range = AdjustedByteRange(0, entry_->bodySize()); + } + progress_receiver_->onBodyInserted(std::move(range), end_stream == EndStream::End); + if (end_stream != EndStream::End) { + AdjustedByteRange next_range(range.end(), range.end() + InsertReadChunkSize); + return source_->getBody(next_range, + [this, next_range](Buffer::InstancePtr buffer, EndStream end_stream) { + onBody(next_range, std::move(buffer), end_stream); + }); + } + delete this; +} + +void InsertContext::onTrailers(Http::ResponseTrailerMapPtr trailers, EndStream end_stream) { + if (end_stream == EndStream::Reset) { + progress_receiver_->onInsertFailed(absl::UnavailableError("upstream reset during trailers")); + } else { + entry_->setTrailers(std::move(trailers)); + progress_receiver_->onTrailersInserted(entry_->copyTrailers()); + } + delete this; +} + +} // namespace + +Buffer::InstancePtr SimpleHttpCache::Entry::body(AdjustedByteRange range) const { + absl::ReaderMutexLock lock(&mu_); + return std::make_unique( + absl::string_view{body_}.substr(range.begin(), range.length())); +} + +void SimpleHttpCache::Entry::appendBody(Buffer::InstancePtr buf) { + absl::WriterMutexLock lock(&mu_); + body_ += buf->toString(); +} + +uint64_t SimpleHttpCache::Entry::bodySize() const { + absl::ReaderMutexLock lock(&mu_); + return body_.size(); +} + +Http::ResponseHeaderMapPtr SimpleHttpCache::Entry::copyHeaders() const { + absl::ReaderMutexLock lock(&mu_); + return Http::createHeaderMap(*response_headers_); +} + +Http::ResponseTrailerMapPtr SimpleHttpCache::Entry::copyTrailers() const { + absl::ReaderMutexLock lock(&mu_); + if (!trailers_) { + return nullptr; + } + return Http::createHeaderMap(*trailers_); +} + +ResponseMetadata SimpleHttpCache::Entry::metadata() const { + absl::ReaderMutexLock lock(&mu_); + return metadata_; +} + +void SimpleHttpCache::Entry::updateHeadersAndMetadata(Http::ResponseHeaderMapPtr response_headers, + ResponseMetadata metadata) { + absl::WriterMutexLock lock(&mu_); + response_headers_ = std::move(response_headers); + metadata_ = std::move(metadata); +} + +void SimpleHttpCache::Entry::setTrailers(Http::ResponseTrailerMapPtr trailers) { + absl::WriterMutexLock lock(&mu_); + trailers_ = std::move(trailers); +} + +void SimpleHttpCache::Entry::setEndStreamAfterBody() { + absl::WriterMutexLock lock(&mu_); + end_stream_after_body_ = true; +} + +CacheInfo SimpleHttpCache::cacheInfo() const { + CacheInfo cache_info; + cache_info.name_ = Name; + return cache_info; +} + +void SimpleHttpCache::lookup(LookupRequest&& request, LookupCallback&& callback) { + LookupResult result; + { + absl::ReaderMutexLock lock(&mu_); + auto it = entries_.find(request.key()); + if (it != entries_.end()) { + result.cache_reader_ = std::make_unique(it->second); + result.response_headers_ = it->second->copyHeaders(); + result.response_metadata_ = it->second->metadata(); + result.response_trailers_ = it->second->copyTrailers(); + result.body_length_ = it->second->bodySize(); + } + } + callback(std::move(result)); +} + +void SimpleHttpCache::evict(Event::Dispatcher&, const Key& key) { + absl::WriterMutexLock lock(&mu_); + entries_.erase(key); +} + +void SimpleHttpCache::updateHeaders(Event::Dispatcher&, const Key& key, + const Http::ResponseHeaderMap& updated_headers, + const ResponseMetadata& updated_metadata) { + absl::WriterMutexLock lock(&mu_); + auto it = entries_.find(key); + if (it == entries_.end()) { + return; + } + it->second->updateHeadersAndMetadata( + Http::createHeaderMap(updated_headers), updated_metadata); +} + +void SimpleHttpCache::insert(Event::Dispatcher&, Key key, Http::ResponseHeaderMapPtr headers, + ResponseMetadata metadata, HttpSourcePtr source, + std::shared_ptr progress) { + auto entry = std::make_shared(Http::createHeaderMap(*headers), + std::move(metadata)); + { + absl::WriterMutexLock lock(&mu_); + entries_.emplace(key, entry); + } + if (source) { + progress->onHeadersInserted(std::make_unique(entry), std::move(headers), + false); + InsertContext::start(entry, std::move(progress), std::move(source)); + } else { + progress->onHeadersInserted(nullptr, std::move(headers), true); + } +} + +SINGLETON_MANAGER_REGISTRATION(simple_http_cache_v2_singleton); + +class SimpleHttpCacheFactory : public HttpCacheFactory { +public: + // From UntypedFactory + std::string name() const override { return std::string(Name); } + // From TypedFactory + ProtobufTypes::MessagePtr createEmptyConfigProto() override { + return std::make_unique< + envoy::extensions::http::cache_v2::simple_http_cache::v3::SimpleHttpCacheV2Config>(); + } + // From HttpCacheFactory + absl::StatusOr> + getCache(const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config&, + Server::Configuration::FactoryContext& context) override { + return context.serverFactoryContext().singletonManager().getTyped( + SINGLETON_MANAGER_REGISTERED_NAME(simple_http_cache_v2_singleton), [&context]() { + return CacheSessions::create(context, std::make_unique()); + }); + } + +private: +}; + +static Registry::RegisterFactory register_; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.h b/source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.h new file mode 100644 index 0000000000000..7aafbd3539a4b --- /dev/null +++ b/source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.h @@ -0,0 +1,64 @@ +#pragma once + +#include "source/common/protobuf/utility.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" +#include "absl/synchronization/mutex.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// Example cache backend that never evicts. Not suitable for production use. +class SimpleHttpCache : public HttpCache { +public: + class Entry { + public: + Entry(Http::ResponseHeaderMapPtr response_headers, ResponseMetadata metadata) + : response_headers_(std::move(response_headers)), metadata_(std::move(metadata)) {} + Buffer::InstancePtr body(AdjustedByteRange range) const; + void appendBody(Buffer::InstancePtr buf); + uint64_t bodySize() const; + Http::ResponseHeaderMapPtr copyHeaders() const; + Http::ResponseTrailerMapPtr copyTrailers() const; + ResponseMetadata metadata() const; + void updateHeadersAndMetadata(Http::ResponseHeaderMapPtr response_headers, + ResponseMetadata metadata); + void setTrailers(Http::ResponseTrailerMapPtr trailers); + void setEndStreamAfterBody(); + + private: + mutable absl::Mutex mu_; + // Body can be being written to while being read from, so mutex guarded. + std::string body_ ABSL_GUARDED_BY(mu_); + Http::ResponseHeaderMapPtr response_headers_ ABSL_GUARDED_BY(mu_); + ResponseMetadata metadata_ ABSL_GUARDED_BY(mu_); + bool end_stream_after_body_{false}; + Http::ResponseTrailerMapPtr trailers_; + }; + + // HttpCache + CacheInfo cacheInfo() const override; + void lookup(LookupRequest&& request, LookupCallback&& callback) override; + void evict(Event::Dispatcher& dispatcher, const Key& key) override; + // Touch is to influence expiry, this implementation has no expiry. + void touch(const Key&, SystemTime) override {} + void updateHeaders(Event::Dispatcher& dispatcher, const Key& key, + const Http::ResponseHeaderMap& updated_headers, + const ResponseMetadata& updated_metadata) override; + void insert(Event::Dispatcher& dispatcher, Key key, Http::ResponseHeaderMapPtr headers, + ResponseMetadata metadata, HttpSourcePtr source, + std::shared_ptr progress) override; + + absl::Mutex mu_; + absl::flat_hash_map, MessageUtil, MessageUtil> + entries_ ABSL_GUARDED_BY(mu_); +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/BUILD b/test/extensions/filters/http/cache_v2/BUILD new file mode 100644 index 0000000000000..f5cbad5ba8113 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/BUILD @@ -0,0 +1,201 @@ +load("//bazel:envoy_build_system.bzl", "envoy_cc_test_library", "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_cc_test_library( + name = "mocks", + srcs = ["mocks.cc"], + hdrs = ["mocks.h"], + deps = [ + "//source/extensions/filters/http/cache_v2:cache_sessions_lib", + "//source/extensions/filters/http/cache_v2:http_cache_lib", + "//source/extensions/filters/http/cache_v2:http_source_interface", + "//source/extensions/filters/http/cache_v2:stats", + "//test/test_common:printers_lib", + ], +) + +envoy_extension_cc_test( + name = "cache_headers_utils_test", + srcs = ["cache_headers_utils_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//envoy/http:header_map_interface", + "//source/common/http:header_map_lib", + "//source/extensions/filters/http/cache_v2:cache_headers_utils_lib", + "//test/mocks/server:server_factory_context_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "stats_test", + srcs = ["stats_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:stats", + "//test/mocks/server:factory_context_mocks", + ], +) + +envoy_extension_cc_test( + name = "cache_entry_utils_test", + srcs = ["cache_entry_utils_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:cache_entry_utils_lib", + ], +) + +envoy_extension_cc_test( + name = "http_cache_test", + srcs = ["http_cache_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:http_cache_lib", + ], +) + +envoy_extension_cc_test( + name = "range_utils_test", + srcs = ["range_utils_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:range_utils_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "upstream_request_test", + srcs = ["upstream_request_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + ":mocks", + "//source/extensions/filters/http/cache_v2:upstream_request_lib", + "//test/mocks/http:http_mocks", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "cache_filter_test", + srcs = ["cache_filter_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + ":mocks", + "//source/extensions/filters/http/cache_v2:cache_filter_lib", + "//test/mocks/buffer:buffer_mocks", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:status_utility_lib", + "//test/test_common:utility_lib", + "@com_google_absl//absl/status", + "@com_google_absl//absl/status:statusor", + ], +) + +envoy_extension_cc_test( + name = "cacheability_utils_test", + srcs = ["cacheability_utils_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:cacheability_utils_lib", + "//test/mocks/server:server_factory_context_mocks", + "//test/test_common:status_utility_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "cache_sessions_test", + srcs = ["cache_sessions_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + ":mocks", + "//source/extensions/filters/http/cache_v2:cache_sessions_impl_lib", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:status_utility_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:config", + "//source/extensions/http/cache_v2/simple_http_cache:config", + "//test/mocks/http:http_mocks", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:utility_lib", + "@envoy_api//envoy/extensions/http/cache_v2/simple_http_cache/v3:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "cache_filter_integration_test", + size = "large", + srcs = [ + "cache_filter_integration_test.cc", + ], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + shard_count = 4, + deps = [ + "//source/extensions/filters/http/cache_v2:config", + "//source/extensions/filters/http/cache_v2:http_cache_lib", + "//source/extensions/http/cache_v2/simple_http_cache:config", + "//test/integration:http_protocol_integration_lib", + "//test/test_common:simulated_time_system_lib", + ], +) + +envoy_extension_cc_test( + name = "cache_custom_headers_test", + srcs = [ + "cache_custom_headers_test.cc", + ], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:cache_custom_headers", + ], +) + +envoy_extension_cc_test_library( + name = "http_cache_implementation_test_common_lib", + srcs = ["http_cache_implementation_test_common.cc"], + hdrs = ["http_cache_implementation_test_common.h"], + extension_names = ["envoy.filters.http.cache_v2"], + rbe_pool = "6gig", + deps = [ + ":mocks", + "//source/extensions/filters/http/cache_v2:cache_headers_utils_lib", + "//source/extensions/filters/http/cache_v2:http_cache_lib", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + "@com_google_absl//absl/status", + "@com_google_absl//absl/synchronization", + ], +) diff --git a/test/extensions/filters/http/cache_v2/cache_custom_headers_test.cc b/test/extensions/filters/http/cache_v2/cache_custom_headers_test.cc new file mode 100644 index 0000000000000..ee57753743848 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cache_custom_headers_test.cc @@ -0,0 +1,81 @@ +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +using RequestHeaderHandle = Http::CustomInlineHeaderRegistry::Handle< + Http::CustomInlineHeaderRegistry::Type::RequestHeaders>; +using ResponseHeaderHandle = Http::CustomInlineHeaderRegistry::Handle< + Http::CustomInlineHeaderRegistry::Type::ResponseHeaders>; + +TEST(CacheCustomHeadersTest, EnsureCacheCustomHeadersGettersDoNotFail) { + Http::TestRequestHeaderMapImpl request_headers_{ + {":path", "/"}, + {":method", "GET"}, + {":scheme", "https"}, + {":authority", "example.com"}, + {"authorization", "Basic abc123def456"}, + {"pragma", "no-cache"}, + {"cache-control", "no-store"}, + {"if-match", "abc123"}, + {"if-none-match", "def456"}, + {"if-modified-since", "16 Oct 2021 07:00:00 GMT"}, + {"if-unmodified-since", "28 Feb 2021 13:00:00 GMT"}, + {"if-range", "ghi789"}}; + + // Ensure we can retrieve each custom header without failure. + const Http::HeaderEntry* authorization = + request_headers_.getInline(CacheCustomHeaders::authorization()); + ASSERT_EQ(authorization->value().getStringView(), "Basic abc123def456"); + const Http::HeaderEntry* pragma = request_headers_.getInline(CacheCustomHeaders::pragma()); + ASSERT_EQ(pragma->value().getStringView(), "no-cache"); + const Http::HeaderEntry* request_cache_control = + request_headers_.getInline(CacheCustomHeaders::requestCacheControl()); + ASSERT_EQ(request_cache_control->value().getStringView(), "no-store"); + const Http::HeaderEntry* if_match = request_headers_.getInline(CacheCustomHeaders::ifMatch()); + ASSERT_EQ(if_match->value().getStringView(), "abc123"); + const Http::HeaderEntry* if_none_match = + request_headers_.getInline(CacheCustomHeaders::ifNoneMatch()); + ASSERT_EQ(if_none_match->value().getStringView(), "def456"); + const Http::HeaderEntry* if_modified_since = + request_headers_.getInline(CacheCustomHeaders::ifModifiedSince()); + ASSERT_EQ(if_modified_since->value().getStringView(), "16 Oct 2021 07:00:00 GMT"); + const Http::HeaderEntry* if_unmodified_since = + request_headers_.getInline(CacheCustomHeaders::ifUnmodifiedSince()); + ASSERT_EQ(if_unmodified_since->value().getStringView(), "28 Feb 2021 13:00:00 GMT"); + const Http::HeaderEntry* if_range = request_headers_.getInline(CacheCustomHeaders::ifRange()); + ASSERT_EQ(if_range->value().getStringView(), "ghi789"); + + Http::TestResponseHeaderMapImpl response_headers_{{":status", "200"}, + {"cache-control", "public,max-age=3600"}, + {"last-modified", "27 Sept 2021 04:00:00 GMT"}, + {"age", "123"}, + {"etag", "abc123"}, + {"expires", "01 Jan 2021 00:00:00 GMT"}}; + + const Http::HeaderEntry* response_cache_control = + response_headers_.getInline(CacheCustomHeaders::responseCacheControl()); + ASSERT_EQ(response_cache_control->value().getStringView(), "public,max-age=3600"); + const Http::HeaderEntry* last_modified = + response_headers_.getInline(CacheCustomHeaders::lastModified()); + ASSERT_EQ(last_modified->value().getStringView(), "27 Sept 2021 04:00:00 GMT"); + const Http::HeaderEntry* age = response_headers_.getInline(CacheCustomHeaders::age()); + ASSERT_EQ(age->value().getStringView(), "123"); + const Http::HeaderEntry* etag = response_headers_.getInline(CacheCustomHeaders::etag()); + ASSERT_EQ(etag->value().getStringView(), "abc123"); + const Http::HeaderEntry* expires = response_headers_.getInline(CacheCustomHeaders::expires()); + ASSERT_EQ(expires->value().getStringView(), "01 Jan 2021 00:00:00 GMT"); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/cache_entry_utils_test.cc b/test/extensions/filters/http/cache_v2/cache_entry_utils_test.cc new file mode 100644 index 0000000000000..a215644a05e16 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cache_entry_utils_test.cc @@ -0,0 +1,113 @@ +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +TEST(Coverage, CacheEntryStatusString) { + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::Hit), "Hit"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::Follower), "Follower"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::Miss), "Miss"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::Uncacheable), "Uncacheable"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::Validated), "Validated"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::ValidatedFree), "ValidatedFree"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::FailedValidation), "FailedValidation"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::FoundNotModified), "FoundNotModified"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::LookupError), "LookupError"); + EXPECT_EQ(cacheEntryStatusString(CacheEntryStatus::UpstreamReset), "UpstreamReset"); + EXPECT_ENVOY_BUG(cacheEntryStatusString(static_cast(99)), + "Unexpected CacheEntryStatus"); +} + +TEST(Coverage, CacheEntryStatusStream) { + std::ostringstream stream; + stream << CacheEntryStatus::Hit; + EXPECT_EQ(stream.str(), "Hit"); +} + +TEST(CacheEntryUtils, ApplyHeaderUpdateReplacesMultiValues) { + Http::TestResponseHeaderMapImpl headers{ + {"test_header", "test_value"}, + {"second_header", "second_value"}, + {"second_header", "additional_value"}, + }; + Http::TestResponseHeaderMapImpl new_headers{ + {"second_header", "new_second_value"}, + }; + applyHeaderUpdate(new_headers, headers); + Http::TestResponseHeaderMapImpl expected{ + {"test_header", "test_value"}, + {"second_header", "new_second_value"}, + }; + EXPECT_THAT(&headers, HeaderMapEqualIgnoreOrder(&expected)); +} + +TEST(CacheEntryUtils, ApplyHeaderUpdateAppliesMultiValues) { + Http::TestResponseHeaderMapImpl headers{ + {"test_header", "test_value"}, + {"second_header", "second_value"}, + }; + Http::TestResponseHeaderMapImpl new_headers{ + {"second_header", "new_second_value"}, + {"second_header", "another_new_second_value"}, + }; + applyHeaderUpdate(new_headers, headers); + Http::TestResponseHeaderMapImpl expected{ + {"test_header", "test_value"}, + {"second_header", "new_second_value"}, + {"second_header", "another_new_second_value"}, + }; + EXPECT_THAT(&headers, HeaderMapEqualIgnoreOrder(&expected)); +} + +TEST(CacheEntryUtils, ApplyHeaderUpdateIgnoresIgnoredValues) { + Http::TestResponseHeaderMapImpl headers{ + {"test_header", "test_value"}, {"etag", "original_etag"}, {"content-length", "123456"}, + {"content-range", "654321"}, {"vary", "original_vary"}, + }; + Http::TestResponseHeaderMapImpl new_headers{ + {"etag", "updated_etag"}, + {"content-length", "999999"}, + {"content-range", "999999"}, + {"vary", "updated_vary"}, + }; + applyHeaderUpdate(new_headers, headers); + Http::TestResponseHeaderMapImpl expected{ + {"test_header", "test_value"}, {"etag", "original_etag"}, {"content-length", "123456"}, + {"content-range", "654321"}, {"vary", "original_vary"}, + }; + EXPECT_THAT(&headers, HeaderMapEqualIgnoreOrder(&expected)); +} + +TEST(CacheEntryUtils, ApplyHeaderUpdateCorrectlyMixesOverwriteIgnoreAddAndPersist) { + Http::TestResponseHeaderMapImpl headers{ + {"persisted_header", "1"}, + {"persisted_header", "2"}, + {"overwritten_header", "old"}, + }; + Http::TestResponseHeaderMapImpl new_headers{ + {"overwritten_header", "new"}, + {"added_header", "also_new"}, + {"etag", "ignored"}, + }; + applyHeaderUpdate(new_headers, headers); + Http::TestResponseHeaderMapImpl expected{ + {"persisted_header", "1"}, + {"persisted_header", "2"}, + {"overwritten_header", "new"}, + {"added_header", "also_new"}, + }; + EXPECT_THAT(&headers, HeaderMapEqualIgnoreOrder(&expected)); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/cache_filter_integration_test.cc b/test/extensions/filters/http/cache_v2/cache_filter_integration_test.cc new file mode 100644 index 0000000000000..55a6cc4d5563b --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cache_filter_integration_test.cc @@ -0,0 +1,896 @@ +#include +#include +#include + +#include "envoy/common/optref.h" + +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" + +#include "test/integration/http_protocol_integration.h" +#include "test/test_common/simulated_time_system.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +using testing::_; +using testing::AllOf; +using testing::Eq; +using testing::HasSubstr; +using testing::Not; +using testing::Pointee; +using testing::Property; + +// TODO(toddmgreer): Expand integration test to include age header values, +// expiration, HEAD requests, config customizations, +// cache-control headers, and conditional header fields, as they are +// implemented. + +class CacheIntegrationTest : public Event::TestUsingSimulatedTime, + public HttpProtocolIntegrationTest { +public: + void SetUp() override { + useAccessLog("%RESPONSE_FLAGS% %RESPONSE_CODE_DETAILS%"); + // Set system time to cause Envoy's cached formatted time to match time on this thread. + simTime().setSystemTime(std::chrono::hours(1)); + } + + void TearDown() override { + cleanupUpstreamAndDownstream(); + HttpProtocolIntegrationTest::TearDown(); + } + + void initializeFilter(const std::string& config) { + config_helper_.prependFilter(config); + initialize(); + codec_client_ = makeHttpConnection(makeClientConnection((lookupPort("http")))); + } + + void initializeFilterWithTrailersEnabled(const std::string& config) { + config_helper_.addFilter(config); + config_helper_.addConfigModifier(setEnableDownstreamTrailersHttp1()); + config_helper_.addConfigModifier(setEnableUpstreamTrailersHttp1()); + initialize(); + codec_client_ = makeHttpConnection(makeClientConnection((lookupPort("http")))); + } + + Http::TestRequestHeaderMapImpl httpRequestHeader(std::string method, std::string authority) { + return {{":method", method}, + {":path", absl::StrCat("/", protocolTestParamsToString({GetParam(), 0}))}, + {":scheme", "http"}, + {":authority", authority}}; + } + + Http::TestResponseHeaderMapImpl httpResponseHeadersForBody( + const std::string& body, const std::string& cache_control = "public,max-age=3600", + std::initializer_list> extra_headers = {}) { + Http::TestResponseHeaderMapImpl response = {{":status", "200"}, + {"date", formatter_.now(simTime())}, + {"cache-control", cache_control}, + {"content-length", std::to_string(body.size())}}; + for (auto& header : extra_headers) { + response.addCopy(header.first, header.second); + } + return response; + } + + IntegrationStreamDecoderPtr sendHeaderOnlyRequest(const Http::TestRequestHeaderMapImpl& headers) { + IntegrationStreamDecoderPtr response_decoder = codec_client_->makeHeaderOnlyRequest(headers); + return response_decoder; + } + + void awaitResponse(IntegrationStreamDecoderPtr& response_decoder) { + EXPECT_TRUE(response_decoder->waitForEndStream()); + } + + IntegrationStreamDecoderPtr sendHeaderOnlyRequestAwaitResponse( + const Http::TestRequestHeaderMapImpl& headers, + std::function simulate_upstream = []() {}) { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequest(headers); + simulate_upstream(); + // Wait for the response to be read by the codec client. + awaitResponse(response_decoder); + return response_decoder; + } + + // split_body allows us to test the behavior when encodeData is in more than one part. + std::function simulateUpstreamResponse( + const Http::TestResponseHeaderMapImpl& headers, OptRef body, + OptRef trailers, bool split_body = false) { + return [this, &headers, body = std::move(body), trailers = std::move(trailers), + split_body]() mutable { + waitForNextUpstreamRequest(); + upstream_request_->encodeHeaders(headers, /*end_stream=*/!body && !trailers.has_value()); + if (body.has_value()) { + if (split_body) { + upstream_request_->encodeData(body.ref().substr(0, body.ref().size() / 2), false); + upstream_request_->encodeData(body.ref().substr(body.ref().size() / 2), + !trailers.has_value()); + } else { + upstream_request_->encodeData(body.ref(), !trailers.has_value()); + } + } + if (trailers.has_value()) { + upstream_request_->encodeTrailers(trailers.ref()); + } + }; + } + std::function serveFromCache() { + return []() {}; + }; + + const std::string default_config{R"EOF( + name: "envoy.filters.http.cache_v2" + typed_config: + "@type": "type.googleapis.com/envoy.extensions.filters.http.cache_v2.v3.CacheV2Config" + typed_config: + "@type": "type.googleapis.com/envoy.extensions.http.cache_v2.simple_http_cache.v3.SimpleHttpCacheV2Config" + )EOF"}; + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; + OptRef no_body_; + OptRef no_trailers_; +}; + +// TODO(#26236): Fix test suite for HTTP/3. +INSTANTIATE_TEST_SUITE_P( + Protocols, CacheIntegrationTest, + testing::ValuesIn(HttpProtocolIntegrationTest::getProtocolTestParamsWithoutHTTP3()), + HttpProtocolIntegrationTest::protocolTestParamsToString); + +TEST_P(CacheIntegrationTest, MissInsertHit) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"MissInsertHit"); + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + + // Send first request, and get response from upstream. + // use split_body to cover multipart body responses. + { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_, true)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time, to verify the original date header is preserved. + simTime().advanceTimeWait(Seconds(10)); + + // Send second request, and get response from cache. + { + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, serveFromCache()); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(response_decoder->headers(), ContainsHeader(Http::CustomHeaders::get().Age, "10")); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), + HasSubstr("RFCF cache.response_from_cache_filter")); + } +} + +TEST_P(CacheIntegrationTest, ParallelRequestsShareInsert) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ParallelRequestsShareInsert"); + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + // Send three requests. + auto codec_client_2 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto codec_client_3 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + IntegrationStreamDecoderPtr response_decoder1 = + codec_client_->makeHeaderOnlyRequest(request_headers); + IntegrationStreamDecoderPtr response_decoder2 = + codec_client_2->makeHeaderOnlyRequest(request_headers); + IntegrationStreamDecoderPtr response_decoder3 = + codec_client_3->makeHeaderOnlyRequest(request_headers); + // Use split_body to cover multipart body responses. + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_, true)(); + awaitResponse(response_decoder1); + awaitResponse(response_decoder2); + awaitResponse(response_decoder3); + EXPECT_THAT(response_decoder1->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder2->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder3->headers(), IsSupersetOfHeaders(response_headers)); + // Two of the responses should have an age, and one should not. + // Which of the requests get the age header depends on the order of + // parallel request resolution, which is not relevant to this test. + EXPECT_THAT(response_decoder1->headers().get(Http::CustomHeaders::get().Age).size() + + response_decoder2->headers().get(Http::CustomHeaders::get().Age).size() + + response_decoder3->headers().get(Http::CustomHeaders::get().Age).size(), + Eq(2)); + EXPECT_EQ(response_decoder1->body(), response_body); + EXPECT_EQ(response_decoder2->body(), response_body); + EXPECT_EQ(response_decoder3->body(), response_body); + codec_client_2->close(); + codec_client_3->close(); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + + EXPECT_THAT(waitForAccessLog(access_log_name_, 0, true), + HasSubstr("RFCF cache.insert_via_upstream")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1, true), + HasSubstr("RFCF cache.response_from_cache_filter")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2, true), + HasSubstr("RFCF cache.response_from_cache_filter")); +} + +TEST_P(CacheIntegrationTest, ParallelRangeRequestsShareInsertAndGetDistinctResponses) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ParallelRequestsShareInsert"); + Http::TestRequestHeaderMapImpl request_headers_2 = request_headers; + Http::TestRequestHeaderMapImpl request_headers_3 = request_headers; + request_headers.setReference(Http::Headers::get().Range, "bytes=0-4"); + request_headers_2.setReference(Http::Headers::get().Range, "bytes=5-9"); + request_headers_3.setReference(Http::Headers::get().Range, "bytes=3-6"); + const std::string response_body("helloworld"); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + // Send three requests. + auto codec_client_2 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + auto codec_client_3 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + IntegrationStreamDecoderPtr response_decoder1 = + codec_client_->makeHeaderOnlyRequest(request_headers); + IntegrationStreamDecoderPtr response_decoder2 = + codec_client_2->makeHeaderOnlyRequest(request_headers_2); + IntegrationStreamDecoderPtr response_decoder3 = + codec_client_3->makeHeaderOnlyRequest(request_headers_3); + // Use split_body to cover multipart body responses. + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_, true)(); + awaitResponse(response_decoder1); + awaitResponse(response_decoder2); + awaitResponse(response_decoder3); + EXPECT_THAT(response_decoder1->headers(), + AllOf(ContainsHeader("content-range", "bytes 0-4/10"), + ContainsHeader("content-length", "5"), ContainsHeader(":status", "206"))); + EXPECT_THAT(response_decoder2->headers(), + AllOf(ContainsHeader("content-range", "bytes 5-9/10"), + ContainsHeader("content-length", "5"), ContainsHeader(":status", "206"))); + EXPECT_THAT(response_decoder3->headers(), + AllOf(ContainsHeader("content-range", "bytes 3-6/10"), + ContainsHeader("content-length", "4"), ContainsHeader(":status", "206"))); + // Two of the responses should have an age, and one should not. + // Which of the requests get the age header depends on the order of + // parallel request resolution, which is not relevant to this test. + EXPECT_THAT(response_decoder1->headers().get(Http::CustomHeaders::get().Age).size() + + response_decoder2->headers().get(Http::CustomHeaders::get().Age).size() + + response_decoder3->headers().get(Http::CustomHeaders::get().Age).size(), + Eq(2)); + EXPECT_EQ(response_decoder1->body(), "hello"); + EXPECT_EQ(response_decoder2->body(), "world"); + EXPECT_EQ(response_decoder3->body(), "lowo"); + codec_client_2->close(); + codec_client_3->close(); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + + EXPECT_THAT(waitForAccessLog(access_log_name_, 0, true), + HasSubstr("RFCF cache.insert_via_upstream")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1, true), + HasSubstr("RFCF cache.response_from_cache_filter")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2, true), + HasSubstr("RFCF cache.response_from_cache_filter")); +} + +TEST_P(CacheIntegrationTest, RequestNoCacheProvokesValidationAndOnFailureInsert) { + initializeFilter(default_config); + Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"RequestNoCacheProvokesValidationAndOnFailureInsert"); + request_headers.setReference(Http::CustomHeaders::get().CacheControl, "no-cache"); + const std::string response_body("helloworld"); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + // send two requests in parallel, they should share a response because + // validation is implicit if it's cacheable and same-time. + auto codec_client_2 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + IntegrationStreamDecoderPtr response_decoder1 = + codec_client_->makeHeaderOnlyRequest(request_headers); + IntegrationStreamDecoderPtr response_decoder2 = + codec_client_2->makeHeaderOnlyRequest(request_headers); + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_, true)(); + EXPECT_THAT(upstream_request_->headers(), AllOf(ContainsHeader("cache-control", "no-cache"), + Not(ContainsHeader("if-modified-since", _)))); + awaitResponse(response_decoder1); + awaitResponse(response_decoder2); + EXPECT_EQ(response_decoder1->body(), "helloworld"); + EXPECT_EQ(response_decoder2->body(), "helloworld"); + codec_client_2->close(); + // send a request subsequent to cache being populated, which should validate + auto codec_client_3 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + IntegrationStreamDecoderPtr response_decoder3 = + codec_client_3->makeHeaderOnlyRequest(request_headers); + // Response with a 200 status, implying validation failed. + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_, true)(); + // Additional upstream request should be a validation, so should have if-modified-since + EXPECT_THAT(upstream_request_->headers(), AllOf(ContainsHeader("cache-control", "no-cache"), + ContainsHeader("if-modified-since", _))); + awaitResponse(response_decoder3); + EXPECT_EQ(response_decoder3->body(), "helloworld"); + codec_client_3->close(); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 0, true), + HasSubstr("RFCF cache.insert_via_upstream")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1, true), + HasSubstr("RFCF cache.response_from_cache_filter")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2, true), + HasSubstr("RFCF cache.insert_via_upstream")); +} + +TEST_P(CacheIntegrationTest, RequestNoCacheProvokesValidationAndOnSuccessReadsFromCache) { + initializeFilter(default_config); + Http::TestRequestHeaderMapImpl request_headers = httpRequestHeader( + "GET", /*authority=*/"RequestNoCacheProvokesValidationAndOnSuccessReadsFromCache"); + request_headers.setReference(Http::CustomHeaders::get().CacheControl, "no-cache"); + const std::string response_body("helloworld"); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + // send two requests in parallel, they should share a response because + // validation is implicit if it's cacheable and same-time. + auto codec_client_2 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + IntegrationStreamDecoderPtr response_decoder1 = + codec_client_->makeHeaderOnlyRequest(request_headers); + IntegrationStreamDecoderPtr response_decoder2 = + codec_client_2->makeHeaderOnlyRequest(request_headers); + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_, true)(); + EXPECT_THAT(upstream_request_->headers(), AllOf(ContainsHeader("cache-control", "no-cache"), + Not(ContainsHeader("if-modified-since", _)))); + awaitResponse(response_decoder1); + awaitResponse(response_decoder2); + EXPECT_EQ(response_decoder1->body(), "helloworld"); + EXPECT_EQ(response_decoder2->body(), "helloworld"); + codec_client_2->close(); + // send a request subsequent to cache being populated, which should validate + auto codec_client_3 = makeHttpConnection(makeClientConnection((lookupPort("http")))); + IntegrationStreamDecoderPtr response_decoder3 = + codec_client_3->makeHeaderOnlyRequest(request_headers); + // Response with a 304 status, implying validation succeeded. + Http::TestResponseHeaderMapImpl response_headers_304{ + {":status", "304"}, {"last-modified", "Mon, 01 Jan 1970 00:30:00 GMT"}}; + simulateUpstreamResponse(response_headers_304, absl::nullopt, no_trailers_, true)(); + // Additional upstream request should be a validation, so should have if-modified-since + EXPECT_THAT(upstream_request_->headers(), AllOf(ContainsHeader("cache-control", "no-cache"), + ContainsHeader("if-modified-since", _))); + awaitResponse(response_decoder3); + EXPECT_EQ(response_decoder3->body(), "helloworld"); + codec_client_3->close(); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 0, true), + HasSubstr("RFCF cache.insert_via_upstream")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1, true), + HasSubstr("RFCF cache.response_from_cache_filter")); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2, true), + HasSubstr("RFCF cache.response_from_cache_filter")); +} + +TEST_P(CacheIntegrationTest, ExpiredValidated) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ExpiredValidated"); + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody( + response_body, /*cache_control=*/"max-age=10", /*extra_headers=*/{{"etag", "abc123"}}); + + // Send first request, and get response from upstream. + { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time for the cached response to be stale (expired) + // Also to make sure response date header gets updated with the 304 date + simTime().advanceTimeWait(Seconds(11)); + + // Send second request, the cached response should be validate then served + { + // Create a 304 (not modified) response -> cached response is valid + const std::string not_modified_date = formatter_.now(simTime()); + const Http::TestResponseHeaderMapImpl not_modified_response_headers = { + {":status", "304"}, {"date", not_modified_date}}; + + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, [&]() { + waitForNextUpstreamRequest(); + // Check for injected precondition headers + EXPECT_THAT(upstream_request_->headers(), ContainsHeader("if-none-match", "abc123")); + + upstream_request_->encodeHeaders(not_modified_response_headers, /*end_stream=*/true); + }); + + // The original response headers should be updated with 304 response headers + response_headers.setDate(not_modified_date); + + // Check that the served response is the cached response + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body(), response_body); + + // A response that has been validated should not contain an Age header as it is equivalent to + // a freshly served response from the origin, unless the 304 response has an Age header, which + // means it was served by an upstream cache. + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + } + // Advance time to get a fresh cached response + simTime().advanceTimeWait(Seconds(1)); + + // Send third request. The cached response was validated, thus it should have an Age header like + // fresh responses + { + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, serveFromCache()); + EXPECT_THAT(response_decoder->headers(), ContainsHeader(Http::CustomHeaders::get().Age, "1")); + + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2), + HasSubstr("RFCF cache.response_from_cache_filter")); + } +} + +TEST_P(CacheIntegrationTest, ExpiredByExpiresHeaderValidated) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ExpiredValidated"); + const std::string response_body(42, 'a'); + auto tenSecondsFromNow = [this]() { + DateFormatter formatter("%a, %d %b %Y %H:%M:%S GMT"); + SystemTime t = simTime().systemTime() + std::chrono::seconds(10); + return formatter.fromTime(t); + }; + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody( + response_body, /*cache_control=*/"", + /*extra_headers=*/{{"expires", tenSecondsFromNow()}, {"etag", "abc123"}}); + + // Send first request, and get response from upstream. + { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time for the cached response to be stale (expired) + // Also to make sure response date header gets updated with the 304 date + simTime().advanceTimeWait(Seconds(11)); + + // Send second request, the cached response should be validate then served + { + // Create a 304 (not modified) response -> cached response is valid + const std::string not_modified_date = formatter_.now(simTime()); + const Http::TestResponseHeaderMapImpl not_modified_response_headers = { + {":status", "304"}, {"date", not_modified_date}, {"expires", tenSecondsFromNow()}}; + + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, [&]() { + waitForNextUpstreamRequest(); + // Check for injected precondition headers + EXPECT_THAT(upstream_request_->headers(), ContainsHeader("if-none-match", "abc123")); + + upstream_request_->encodeHeaders(not_modified_response_headers, /*end_stream=*/true); + }); + + // The original response headers should be updated with 304 response headers + response_headers.setDate(not_modified_date); + response_headers.setInline(CacheCustomHeaders::expires(), tenSecondsFromNow()); + + // Check that the served response is the cached response + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body(), response_body); + + // A response that has been validated should not contain an Age header as it is equivalent to + // a freshly served response from the origin, unless the 304 response has an Age header, which + // means it was served by an upstream cache. + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + } + // Advance time to get a fresh cached response + simTime().advanceTimeWait(Seconds(1)); + + // Send third request. The cached response was validated, thus it should have an Age header like + // fresh responses + { + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, serveFromCache()); + EXPECT_THAT(response_decoder->headers(), ContainsHeader(Http::CustomHeaders::get().Age, "1")); + + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2), + HasSubstr("RFCF cache.response_from_cache_filter")); + } +} + +TEST_P(CacheIntegrationTest, TemporarilyUncacheableEventuallyCaches) { + initializeFilterWithTrailersEnabled(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"TemporarilyUncacheableEventuallyCaches"); + const Http::TestResponseTrailerMapImpl response_trailers = {{"x-test", "yes"}}; + std::string response_body{"aaaaaaaaaa"}; + Http::TestResponseHeaderMapImpl cacheable_response_headers{ + {":status", "200"}, {"cache-control", "max-age=10"}, {"etag", "abc123"}}; + + // Send first request, and get 500 response from upstream. + { + Http::TestResponseHeaderMapImpl response_headers{{":status", "500"}}; + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, absl::nullopt, response_trailers)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->body(), Eq("")); + EXPECT_THAT(response_decoder->trailers(), Pointee(IsSupersetOfHeaders(response_trailers))); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + // Send second request, and get cacheable 200 response from upstream. + // This should reset the uncacheable state imposed by the first request. + // *Ideally* this would write to the cache this time as well, but getting + // to this state means we already started an inexpensive pass-through, so + // it's too late to start writing to the cache from this request without + // adding unnecessary complexity. + { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(cacheable_response_headers, response_body, response_trailers)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(cacheable_response_headers)); + EXPECT_THAT(response_decoder->body(), Eq(response_body)); + EXPECT_THAT(response_decoder->trailers(), Pointee(IsSupersetOfHeaders(response_trailers))); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), HasSubstr("via_upstream")); + } + // Send third request, and get cacheable 200 response from upstream, it should be cached this + // time. + { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(cacheable_response_headers, response_body, response_trailers)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(cacheable_response_headers)); + EXPECT_THAT(response_decoder->body(), Eq(response_body)); + EXPECT_THAT(response_decoder->trailers(), Pointee(IsSupersetOfHeaders(response_trailers))); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 2), HasSubstr("cache.insert_via_upstream")); + } +} + +TEST_P(CacheIntegrationTest, ExpiredFetchedNewResponse) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ExpiredFetchedNewResponse"); + + // Send first request, and get response from upstream. + { + const std::string response_body(10, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody( + response_body, /*cache_control=*/"max-age=10", /*extra_headers=*/{{"etag", "a1"}}); + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time for the cached response to be stale (expired) + // Also to make sure response date header gets updated with the 304 date + simTime().advanceTimeWait(Seconds(11)); + + // Send second request, validation of the cached response should be attempted but should fail + // The new response should be served + { + const std::string response_body(20, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody( + response_body, + /*cache_control=*/"max-age=10", /*extra_headers=*/{{"etag", "a2"}}); + + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, [&]() { + waitForNextUpstreamRequest(); + // Check for injected precondition headers + EXPECT_THAT(upstream_request_->headers(), ContainsHeader("if-none-match", "a1")); + + // Reply with the updated response -> cached response is invalid + upstream_request_->encodeHeaders(response_headers, /*end_stream=*/false); + // send 20 'a's + upstream_request_->encodeData(response_body, /*end_stream=*/true); + }); + // Check that the served response is the updated response + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body(), response_body); + // Check that age header does not exist as this is not a cached response + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), HasSubstr("via_upstream")); + } +} + +// Send the same GET request with body and trailers twice, then check that the response +// doesn't have an age header, to confirm that it wasn't served from cache. +TEST_P(CacheIntegrationTest, GetRequestWithBodyAndTrailers) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"GetRequestWithBodyAndTrailers"); + + Http::TestRequestTrailerMapImpl request_trailers{{"request1", "trailer1"}, + {"request2", "trailer2"}}; + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + + for (int i = 0; i < 2; ++i) { + auto encoder_decoder = codec_client_->startRequest(request_headers); + request_encoder_ = &encoder_decoder.first; + auto response = std::move(encoder_decoder.second); + codec_client_->sendData(*request_encoder_, 13, false); + codec_client_->sendTrailers(*request_encoder_, request_trailers); + waitForNextUpstreamRequest(); + upstream_request_->encodeHeaders(response_headers, /*end_stream=*/false); + // send 42 'a's + upstream_request_->encodeData(42, true); + // Wait for the response to be read by the codec client. + ASSERT_TRUE(response->waitForEndStream(std::chrono::milliseconds(1000))); + EXPECT_THAT(response->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response->headers(), Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response->body(), std::string(42, 'a')); + } +} + +TEST_P(CacheIntegrationTest, GetRequestWithResponseTrailers) { + initializeFilterWithTrailersEnabled(default_config); + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"GetRequestWithResponseTrailers"); + + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = {{":status", "200"}, + {"date", formatter_.now(simTime())}, + {"cache-control", "public,max-age=3600"}}; + const Http::TestResponseTrailerMapImpl response_trailers{{"response1", "trailer1"}, + {"response2", "trailer2"}}; + // Send GET request, receive a response from upstream, cache it + { + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, simulateUpstreamResponse(response_headers, makeOptRef(response_body), + makeOptRef(response_trailers))); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(response_decoder->trailers(), Pointee(IsSupersetOfHeaders(response_trailers))); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time, to verify the original date header is preserved. + simTime().advanceTimeWait(Seconds(10)); + // Send second request, and get response from cache. + { + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, serveFromCache()); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), ContainsHeader(Http::CustomHeaders::get().Age, "10")); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(response_decoder->trailers(), Pointee(IsSupersetOfHeaders(response_trailers))); + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), + HasSubstr("RFCF cache.response_from_cache_filter")); + } +} + +TEST_P(CacheIntegrationTest, ServeHeadRequest) { + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader(Http::Headers::get().MethodValues.Head, "ServeHeadRequest"); + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + + // Send first request, and get response from upstream. + { + // Since it is a head request, no need to encodeData => the response_body is absl::nullopt. + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, simulateUpstreamResponse(response_headers, no_body_, no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body().size(), 0); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time, to verify the original date header is preserved. + simTime().advanceTimeWait(Seconds(10)); + + // Send second request, and get response from upstream, since the head requests are not stored + // in cache. + { + // Since it is a head request, no need to encodeData => the response_body is empty. + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, simulateUpstreamResponse(response_headers, no_body_, no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body().size(), 0); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), HasSubstr("via_upstream")); + } +} + +TEST_P(CacheIntegrationTest, ServeHeadFromCacheAfterGetRequest) { + initializeFilter(default_config); + + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + + // Send GET request, and get response from upstream. + { + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ServeHeadFromCacheAfterGetRequest"); + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + // Advance time, to verify the original date header is preserved. + simTime().advanceTimeWait(Seconds(10)); + + // Send HEAD request, and get response from cache. + { + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("HEAD", "ServeHeadFromCacheAfterGetRequest"); + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, serveFromCache()); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body().size(), 0); + EXPECT_THAT(response_decoder->headers(), ContainsHeader(Http::CustomHeaders::get().Age, "10")); + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), + HasSubstr("RFCF cache.response_from_cache_filter")); + } +} + +TEST_P(CacheIntegrationTest, ServeGetFromUpstreamAfterHeadRequest) { + initializeFilter(default_config); + + const std::string response_body(42, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody(response_body); + + // Send HEAD request, and get response from upstream. + { + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("HEAD", "ServeGetFromUpstreamAfterHeadRequest"); + // No need to encode the data, therefore response_body is empty. + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, simulateUpstreamResponse(response_headers, no_body_, no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body().size(), 0); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Send GET request, and get response from upstream. + { + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ServeGetFromUpstreamAfterHeadRequest"); + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), HasSubstr("via_upstream")); + } +} + +TEST_P(CacheIntegrationTest, ServeGetFollowedByHead200ThatNeedsValidationPassesThroughHeadRequest) { + initializeFilter(default_config); + + // Send GET request, and get response from upstream. + { + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("GET", /*authority=*/"ServeGetFollowedByHead200WithValidation"); + const std::string response_body(10, 'a'); + Http::TestResponseHeaderMapImpl response_headers = httpResponseHeadersForBody( + response_body, /*cache-control*/ "max-age=10", /*extra_headers=*/{{"etag", "a1"}}); + + IntegrationStreamDecoderPtr response_decoder = sendHeaderOnlyRequestAwaitResponse( + request_headers, + simulateUpstreamResponse(response_headers, makeOptRef(response_body), no_trailers_)); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + EXPECT_EQ(response_decoder->body(), response_body); + EXPECT_THAT(waitForAccessLog(access_log_name_), HasSubstr("via_upstream")); + } + + // Advance time for the cached response to be stale (expired) + // Also to make sure response date header gets updated with the 304 date + simTime().advanceTimeWait(Seconds(11)); + + // Send HEAD request, validation of the cached response should be attempted but should fail + { + // Include test name and params in URL to make each test's requests unique. + const Http::TestRequestHeaderMapImpl request_headers = + httpRequestHeader("HEAD", "ServeGetFollowedByHead200WithValidation"); + const std::string response_body(20, 'a'); + Http::TestResponseHeaderMapImpl response_headers = + httpResponseHeadersForBody(response_body, + /*cache_control=*/"max-age=10", + /*extra_headers=*/{{"etag", "a2"}}); + + IntegrationStreamDecoderPtr response_decoder = + sendHeaderOnlyRequestAwaitResponse(request_headers, [&]() { + waitForNextUpstreamRequest(); + + // Reply with the updated response -> cached response is invalid + upstream_request_->encodeHeaders(response_headers, + /*end_stream=*/true); + }); + EXPECT_THAT(response_decoder->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(response_decoder->body().size(), 0); + // Check that age header does not exist as this is not a cached response + EXPECT_THAT(response_decoder->headers(), + Not(ContainsHeader(Http::CustomHeaders::get().Age, _))); + + // Advance time to force a log flush. + simTime().advanceTimeWait(Seconds(1)); + EXPECT_THAT(waitForAccessLog(access_log_name_, 1), HasSubstr("via_upstream")); + } +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/cache_filter_test.cc b/test/extensions/filters/http/cache_v2/cache_filter_test.cc new file mode 100644 index 0000000000000..bababeb3a16b6 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cache_filter_test.cc @@ -0,0 +1,659 @@ +#include + +#include "envoy/event/dispatcher.h" + +#include "source/common/http/headers.h" +#include "source/extensions/filters/http/cache_v2/cache_filter.h" + +#include "test/extensions/filters/http/cache_v2/mocks.h" +#include "test/mocks/buffer/mocks.h" +#include "test/mocks/server/factory_context.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/status_utility.h" +#include "test/test_common/utility.h" + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +using ::Envoy::StatusHelpers::IsOk; +using ::Envoy::StatusHelpers::IsOkAndHolds; +using ::testing::_; +using ::testing::Eq; +using ::testing::Gt; +using ::testing::IsNull; +using ::testing::Not; +using ::testing::NotNull; +using ::testing::Optional; +using ::testing::Property; +using ::testing::Return; + +class CacheFilterTest : public ::testing::Test { +protected: + CacheFilterSharedPtr makeFilter(std::shared_ptr cache, bool auto_destroy = true) { + auto config = std::make_shared(config_, std::move(cache), + context_.server_factory_context_); + std::shared_ptr filter(new CacheFilter(config), [auto_destroy](CacheFilter* f) { + if (auto_destroy) { + f->onDestroy(); + } + delete f; + }); + filter->setDecoderFilterCallbacks(decoder_callbacks_); + filter->setEncoderFilterCallbacks(encoder_callbacks_); + return filter; + } + + void SetUp() override { + ON_CALL(encoder_callbacks_, dispatcher()).WillByDefault(::testing::ReturnRef(*dispatcher_)); + ON_CALL(decoder_callbacks_, dispatcher()).WillByDefault(::testing::ReturnRef(*dispatcher_)); + ON_CALL(decoder_callbacks_.stream_info_, filterState()) + .WillByDefault(::testing::ReturnRef(filter_state_)); + // Initialize the time source (otherwise it returns the real time) + time_source_.setSystemTime(std::chrono::hours(1)); + // Use the initialized time source to set the response date header + response_headers_.setDate(formatter_.now(time_source_)); + ON_CALL(*mock_cache_, lookup) + .WillByDefault([this](ActiveLookupRequestPtr request, ActiveLookupResultCallback&& cb) { + captured_lookup_request_ = std::move(request); + captured_lookup_callback_ = std::move(cb); + }); + context_.server_factory_context_.cluster_manager_.initializeThreadLocalClusters( + {"fake_cluster"}); + ON_CALL(*mock_http_source_, getHeaders).WillByDefault([this](GetHeadersCallback&& cb) { + EXPECT_THAT(captured_get_headers_callback_, IsNull()); + captured_get_headers_callback_ = std::move(cb); + }); + ON_CALL(*mock_http_source_, getBody) + .WillByDefault([this](AdjustedByteRange, GetBodyCallback&& cb) { + // getBody can be called multiple times so overwriting body callback makes sense. + captured_get_body_callback_ = std::move(cb); + }); + ON_CALL(*mock_http_source_, getTrailers).WillByDefault([this](GetTrailersCallback&& cb) { + EXPECT_THAT(captured_get_trailers_callback_, IsNull()); + captured_get_trailers_callback_ = std::move(cb); + }); + } + + void pumpDispatcher() { dispatcher_->run(Event::Dispatcher::RunType::Block); } + + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config config_; + std::shared_ptr filter_state_ = + std::make_shared(StreamInfo::FilterState::LifeSpan::FilterChain); + NiceMock context_; + Event::SimulatedTimeSystem time_source_; + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; + Http::TestRequestHeaderMapImpl request_headers_{ + {":path", "/"}, {"host", "fake_host"}, {":method", "GET"}, {":scheme", "https"}}; + Http::TestResponseHeaderMapImpl response_headers_{{":status", "200"}, + {"cache-control", "public,max-age=3600"}}; + Http::TestResponseTrailerMapImpl response_trailers_{{"x-test-trailer", "yes"}}; + NiceMock decoder_callbacks_; + NiceMock encoder_callbacks_; + Api::ApiPtr api_ = Api::createApiForTest(); + Event::DispatcherPtr dispatcher_ = api_->allocateDispatcher("test_thread"); + std::shared_ptr mock_cache_ = std::make_shared(); + std::unique_ptr mock_http_source_ = std::make_unique(); + MockCacheFilterStats& stats() { return mock_cache_->mock_stats_; } + ActiveLookupRequestPtr captured_lookup_request_; + ActiveLookupResultCallback captured_lookup_callback_; + GetHeadersCallback captured_get_headers_callback_; + GetBodyCallback captured_get_body_callback_; + GetTrailersCallback captured_get_trailers_callback_; +}; +class CacheFilterDeathTest : public CacheFilterTest {}; + +MATCHER_P(RangeStartsWith, v, "") { + return ::testing::ExplainMatchResult(::testing::Property("begin", &AdjustedByteRange::begin, v), + arg, result_listener); +} + +MATCHER_P2(IsRange, start, end, "") { + return ::testing::ExplainMatchResult( + ::testing::AllOf(::testing::Property("begin", &AdjustedByteRange::begin, start), + ::testing::Property("end", &AdjustedByteRange::end, end)), + arg, result_listener); +} + +TEST_F(CacheFilterTest, PassThroughIfCacheDisabled) { + auto filter = makeFilter(nullptr); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::Continue)); + EXPECT_THAT(filter->encodeHeaders(response_headers_, true), + Eq(Http::FilterHeadersStatus::Continue)); + // Details should not have been set by cache filter. + EXPECT_THAT(decoder_callbacks_.details(), Eq("")); +} + +TEST_F(CacheFilterTest, PassThroughIfRequestHasBody) { + auto filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Uncacheable)); + EXPECT_THAT(filter->decodeHeaders(request_headers_, false), + Eq(Http::FilterHeadersStatus::Continue)); + Buffer::OwnedImpl body("a"); + EXPECT_THAT(filter->decodeData(body, true), Eq(Http::FilterDataStatus::Continue)); + EXPECT_THAT(filter->encodeHeaders(response_headers_, true), + Eq(Http::FilterHeadersStatus::Continue)); + // Details should not have been set by cache filter. + EXPECT_THAT(decoder_callbacks_.details(), Eq("")); +} + +TEST_F(CacheFilterTest, PassThroughIfCacheabilityIsNo) { + auto filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Uncacheable)); + request_headers_.addCopy(Http::CustomHeaders::get().IfNoneMatch, "1"); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::Continue)); + EXPECT_THAT(filter->encodeHeaders(response_headers_, true), + Eq(Http::FilterHeadersStatus::Continue)); + // Details should not have been set by cache filter. + EXPECT_THAT(decoder_callbacks_.details(), Eq("")); +} + +TEST_F(CacheFilterTest, NoRouteShouldLocalReply) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(decoder_callbacks_, route()).WillOnce(Return(nullptr)); + EXPECT_CALL(decoder_callbacks_, sendLocalReply(Http::Code::NotFound, _, _, _, "cache_no_route")); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache_no_route")); +} + +TEST_F(CacheFilterTest, NoClusterShouldLocalReply) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(context_.server_factory_context_.cluster_manager_, getThreadLocalCluster(_)) + .WillOnce(Return(nullptr)); + EXPECT_CALL(decoder_callbacks_, + sendLocalReply(Http::Code::ServiceUnavailable, _, _, _, "cache_no_cluster")); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache_no_cluster")); +} + +TEST_F(CacheFilterTest, OverriddenClusterShouldTryThatCluster) { + config_.set_override_upstream_cluster("overridden_cluster"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + // Validate that the specified cluster was *tried*; letting it not exist + // to keep the test simple. + EXPECT_CALL(context_.server_factory_context_.cluster_manager_, + getThreadLocalCluster("overridden_cluster")) + .WillOnce(Return(nullptr)); + EXPECT_CALL(decoder_callbacks_, + sendLocalReply(Http::Code::ServiceUnavailable, _, _, _, "cache_no_cluster")); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache_no_cluster")); +} + +TEST_F(CacheFilterDeathTest, TimeoutBeforeLookupCompletesImpliesABug) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_, /* auto_destroy = */ false); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + ASSERT_THAT(captured_lookup_callback_, NotNull()); + // Validate some request fields; this can be omitted for other tests since + // everything should be the same. + EXPECT_THAT(captured_lookup_request_->key().host(), Eq("fake_host")); + EXPECT_THAT(captured_lookup_request_->requestHeaders(), IsSupersetOfHeaders(request_headers_)); + EXPECT_THAT(&captured_lookup_request_->dispatcher(), Eq(dispatcher_.get())); + + response_headers_.setStatus(absl::StrCat(Envoy::enumToInt(Http::Code::RequestTimeout))); + EXPECT_ENVOY_BUG(filter->encodeHeaders(response_headers_, true), + "Request timed out while cache lookup was outstanding."); +} + +TEST_F(CacheFilterTest, EncodeHeadersBeforeLookupCompletesAbortsTheLookupCallback) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + ASSERT_THAT(captured_lookup_callback_, NotNull()); + EXPECT_THAT(filter->encodeHeaders(response_headers_, true), + Eq(Http::FilterHeadersStatus::Continue)); + // A null lookup result is disallowed; encodeHeaders being called before it + // completes should have cancelled the callback, so calling it now with invalid + // data proves the cancellation has taken effect. + captured_lookup_callback_(nullptr); + // Since filter was aborted it should not have set response code details. + EXPECT_THAT(decoder_callbacks_.details(), Eq("")); +} + +TEST_F(CacheFilterTest, FilterDestroyedBeforeLookupCompletesAbortsTheLookupCallback) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + ASSERT_THAT(captured_lookup_callback_, NotNull()); + filter.reset(); + // Callback with nullptr would be invalid *and* would be operating on a + // now-defunct filter pointer - so calling it proves it was cancelled. + captured_lookup_callback_(nullptr); +} + +TEST_F(CacheFilterTest, ResetDuringLookupResetsDownstream) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(decoder_callbacks_, resetStream); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{nullptr, CacheEntryStatus::LookupError})); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.aborted_lookup")); +} + +TEST_F(CacheFilterTest, ResetDuringGetHeadersResetsDownstream) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + EXPECT_CALL(decoder_callbacks_, resetStream); + captured_get_headers_callback_(nullptr, EndStream::Reset); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.aborted_headers")); +} + +TEST_F(CacheFilterTest, GetHeadersWithHeadersOnlyResponseCompletes) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), true)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::End); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.insert_via_upstream")); +} + +TEST_F(CacheFilterTest, PartialContentCodeWithNoContentRangeGivesFullContent) { + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, Gt(500)), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); +} + +TEST_F(CacheFilterTest, PartialContentCodeWithInvalidContentRangeGivesFullContent) { + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "invalid-value"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, Gt(500)), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); +} + +TEST_F(CacheFilterTest, PartialContentCodeWithInvalidContentRangeNumberGivesFullContent) { + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "bytes */invalid"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, Gt(500)), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); +} + +TEST_F(CacheFilterTest, PartialContentCodeWithWildContentRangeUsesSize) { + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "bytes */100"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, 100), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); +} + +TEST_F(CacheFilterTest, PartialContentCodeWithInvalidRangeElementsDefaultsToZeroAndMax) { + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "bytes invalid-invalid/100"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, Gt(500)), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); +} + +TEST_F(CacheFilterTest, DestroyedDuringEncodeHeadersPreventsGetBody) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_, false); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)) + .WillOnce([&filter](Http::ResponseHeaderMap&, bool) { filter->onDestroy(); }); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); +} + +TEST_F(CacheFilterTest, ResetDuringGetBodyResetsDownstream) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + EXPECT_CALL(decoder_callbacks_, resetStream); + captured_get_body_callback_(nullptr, EndStream::Reset); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.aborted_body")); +} + +TEST_F(CacheFilterTest, GetBodyAdvancesRequestRange) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Miss)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(0), _)); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(5), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Miss})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("hello"), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual(" world!"), true)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(std::make_unique("hello"), EndStream::More); + captured_get_body_callback_(std::make_unique(" world!"), EndStream::End); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.insert_via_upstream")); +} + +TEST_F(CacheFilterTest, GetBodyReturningNullBufferAndEndStreamCompletes) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(0), _)); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(5), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("hello"), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual(""), true)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(std::make_unique("hello"), EndStream::More); + captured_get_body_callback_(nullptr, EndStream::End); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.response_from_cache_filter")); +} + +TEST_F(CacheFilterTest, GetBodyReturningNullBufferAndNoEndStreamGoesOnToTrailers) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(0), _)); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(5), _)); + EXPECT_CALL(*mock_http_source_, getTrailers); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("hello"), false)); + EXPECT_CALL(decoder_callbacks_, encodeTrailers_(IsSupersetOfHeaders(response_trailers_))); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(std::make_unique("hello"), EndStream::More); + captured_get_body_callback_(nullptr, EndStream::More); + captured_get_trailers_callback_(createHeaderMap(response_trailers_), + EndStream::End); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.response_from_cache_filter")); +} + +TEST_F(CacheFilterDeathTest, GetBodyReturningBufferLargerThanRequestedIsABug) { + request_headers_.addCopy("range", "bytes=0-5"); + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "bytes 0-5/12"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, 6), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + EXPECT_ENVOY_BUG(captured_get_body_callback_(std::make_unique("hello world!"), + EndStream::End), + "Received oversized body from http source."); +} + +TEST_F(CacheFilterTest, EndOfRequestedRangeEndsStreamWhenUpstreamDoesNot) { + request_headers_.addCopy("range", "bytes=0-4"); + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "bytes 0-4/12"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(0, 5), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("hello"), true)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(std::make_unique("hello"), EndStream::More); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.response_from_cache_filter")); +} + +TEST_F(CacheFilterTest, EndOfRequestedRangeEndsTraileredStreamWithoutSendingTrailers) { + request_headers_.addCopy("range", "bytes=8-11"); + response_headers_.setStatus(std::to_string(enumToInt(Http::Code::PartialContent))); + response_headers_.addCopy("content-range", "bytes 8-11/12"); + CacheFilterSharedPtr filter = makeFilter(mock_cache_); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(IsRange(8, 12), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("beep"), true)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + // More here, at the end of the source data, indicates that trailers exist. + captured_get_body_callback_(std::make_unique("beep"), EndStream::More); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.response_from_cache_filter")); +} + +TEST_F(CacheFilterTest, FilterDestroyedDuringEncodeDataPreventsFurtherRequests) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_, false); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("hello"), false)) + .WillOnce([&filter]() { filter->onDestroy(); }); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(std::make_unique("hello"), EndStream::More); + // Destruction of filter should prevent "more" from being requested. +} + +TEST_F(CacheFilterTest, WatermarkDelaysUpstreamRequestingMore) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_, false); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(0), _)); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(5), _)); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("hello"), false)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + filter->onAboveWriteBufferHighWatermark(); + // Move captured_get_body_callback_ into another variable so that it being + // nullptr can be used to ensure the second callback is not in flight. + auto cb = std::move(captured_get_body_callback_); + captured_get_body_callback_ = nullptr; + cb(std::make_unique("hello"), EndStream::More); + // A new callback should not be in flight because of the watermark. + EXPECT_THAT(captured_get_body_callback_, IsNull()); + // Watermark deeper! + filter->onAboveWriteBufferHighWatermark(); + // Unwatermarking one level should not release the request. + filter->onBelowWriteBufferLowWatermark(); + EXPECT_THAT(captured_get_body_callback_, IsNull()); + // Unwatermarking back to zero should release the request. + filter->onBelowWriteBufferLowWatermark(); + EXPECT_THAT(captured_get_body_callback_, NotNull()); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("world"), true)); + captured_get_body_callback_(std::make_unique("world"), EndStream::End); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.response_from_cache_filter")); +} + +TEST_F(CacheFilterTest, DeepRecursionOfGetBodyDoesntOverflowStack) { + // Since it's possible for a cache to call back with body data instantly without + // posting it to a dispatcher, we want to be sure that the implementation + // doesn't cause a buffer overflow if that happens *a lot*. + uint64_t depth = 0; + uint64_t max_depth = 60000; + CacheFilterSharedPtr filter = makeFilter(mock_cache_, false); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody) + .WillRepeatedly([&depth, &max_depth](AdjustedByteRange range, GetBodyCallback&& cb) { + ASSERT_THAT(range.begin(), Eq(depth)); + if (++depth < max_depth) { + return cb(std::make_unique("a"), EndStream::More); + } else { + return cb(std::make_unique("a"), EndStream::End); + } + }); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("a"), false)).Times(max_depth - 1); + EXPECT_CALL(decoder_callbacks_, encodeData(BufferStringEqual("a"), true)); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.response_from_cache_filter")); +} + +TEST_F(CacheFilterTest, FilterDestroyedDuringEncodeTrailersPreventsFurtherAction) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_, false); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody); + EXPECT_CALL(*mock_http_source_, getTrailers); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, encodeTrailers_(IsSupersetOfHeaders(response_trailers_))) + .WillOnce([&filter]() { filter->onDestroy(); }); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(nullptr, EndStream::More); + captured_get_trailers_callback_(createHeaderMap(response_trailers_), + EndStream::End); + // Destruction of filter should prevent finalizeEncodingCachedResponse, but + // that's undetectable right now because it doesn't do anything anyway. +} + +TEST_F(CacheFilterTest, FilterResetDuringEncodeTrailersResetsDownstream) { + CacheFilterSharedPtr filter = makeFilter(mock_cache_, false); + EXPECT_CALL(stats(), incForStatus(CacheEntryStatus::Hit)); + EXPECT_CALL(*mock_cache_, lookup); + EXPECT_THAT(filter->decodeHeaders(request_headers_, true), + Eq(Http::FilterHeadersStatus::StopIteration)); + EXPECT_CALL(*mock_http_source_, getHeaders); + EXPECT_CALL(*mock_http_source_, getBody(RangeStartsWith(0), _)); + EXPECT_CALL(*mock_http_source_, getTrailers); + captured_lookup_callback_(std::make_unique( + ActiveLookupResult{std::move(mock_http_source_), CacheEntryStatus::Hit})); + EXPECT_CALL(decoder_callbacks_, encodeHeaders_(IsSupersetOfHeaders(response_headers_), false)); + EXPECT_CALL(decoder_callbacks_, resetStream); + captured_get_headers_callback_(createHeaderMap(response_headers_), + EndStream::More); + captured_get_body_callback_(nullptr, EndStream::More); + captured_get_trailers_callback_(nullptr, EndStream::Reset); + EXPECT_THAT(decoder_callbacks_.details(), Eq("cache.aborted_trailers")); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/cache_headers_utils_test.cc b/test/extensions/filters/http/cache_v2/cache_headers_utils_test.cc new file mode 100644 index 0000000000000..7d3daf0bdf89a --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cache_headers_utils_test.cc @@ -0,0 +1,942 @@ +#include +#include +#include + +#include "envoy/common/time.h" + +#include "source/common/common/macros.h" +#include "source/common/common/utility.h" +#include "source/common/http/header_map_impl.h" +#include "source/common/http/header_utility.h" +#include "source/extensions/filters/http/cache_v2/cache_custom_headers.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" + +#include "test/mocks/server/server_factory_context.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 CacheV2 { +namespace { + +Protobuf::RepeatedPtrField<::envoy::type::matcher::v3::StringMatcher> +toStringMatchers(std::initializer_list allow_list) { + Protobuf::RepeatedPtrField<::envoy::type::matcher::v3::StringMatcher> proto_allow_list; + for (const auto& rule : allow_list) { + ::envoy::type::matcher::v3::StringMatcher* matcher = proto_allow_list.Add(); + matcher->set_exact(std::string(rule)); + } + + return proto_allow_list; +} + +struct TestRequestCacheControl : public RequestCacheControl { + TestRequestCacheControl(bool must_validate, bool no_store, bool no_transform, bool only_if_cached, + OptionalDuration max_age, OptionalDuration min_fresh, + OptionalDuration max_stale) { + must_validate_ = must_validate; + no_store_ = no_store; + no_transform_ = no_transform; + only_if_cached_ = only_if_cached; + max_age_ = max_age; + min_fresh_ = min_fresh; + max_stale_ = max_stale; + } +}; + +struct RequestCacheControlTestCase { + absl::string_view cache_control_header; + TestRequestCacheControl request_cache_control; +}; + +class RequestCacheControlTest : public testing::TestWithParam { +public: + static const std::vector& getTestCases() { + // clang-format off + CONSTRUCT_ON_FIRST_USE(std::vector, + // Empty header + { + "", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, absl::nullopt, absl::nullopt, absl::nullopt} + }, + // Valid cache-control headers + { + "max-age=3600, min-fresh=10, no-transform, only-if-cached, no-store", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, true, true, true, Seconds(3600), Seconds(10), absl::nullopt} + }, + { + "min-fresh=100, max-stale, no-cache", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {true, false, false, false, absl::nullopt, Seconds(100), SystemTime::duration::max()} + }, + { + "max-age=10, max-stale=50", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, Seconds(10), absl::nullopt, Seconds(50)} + }, + // Quoted arguments are interpreted correctly + { + "max-age=\"3600\", min-fresh=\"10\", no-transform, only-if-cached, no-store", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, true, true, true, Seconds(3600), Seconds(10), absl::nullopt} + }, + { + "max-age=\"10\", max-stale=\"50\", only-if-cached", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, true, Seconds(10), absl::nullopt, Seconds(50)} + }, + // Unknown directives are ignored + { + "max-age=10, max-stale=50, unknown-directive", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, Seconds(10), absl::nullopt, Seconds(50)} + }, + { + "max-age=10, max-stale=50, unknown-directive-with-arg=arg1", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, Seconds(10), absl::nullopt, Seconds(50)} + }, + { + "max-age=10, max-stale=50, unknown-directive-with-quoted-arg=\"arg1\"", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, Seconds(10), absl::nullopt, Seconds(50)} + }, + { + "max-age=10, max-stale=50, unknown-directive, unknown-directive-with-quoted-arg=\"arg1\"", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, Seconds(10), absl::nullopt, Seconds(50)} + }, + // Invalid durations are ignored + { + "max-age=five, min-fresh=30, no-store", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, true, false, false, absl::nullopt, Seconds(30), absl::nullopt} + }, + { + "max-age=five, min-fresh=30s, max-stale=-2", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, absl::nullopt, absl::nullopt, absl::nullopt} + }, + { + "max-age=\"", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {false, false, false, false, absl::nullopt, absl::nullopt, absl::nullopt} + }, + // Invalid parts of the header are ignored + { + "no-cache, ,,,fjfwioen3298, max-age=20, min-fresh=30=40", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {true, false, false, false, Seconds(20), absl::nullopt, absl::nullopt} + }, + // If a directive argument contains a comma by mistake + // the part before the comma will be interpreted as the argument + // and the part after it will be ignored + { + "no-cache, max-age=10,0, no-store", + // {must_validate_, no_store_, no_transform_, only_if_cached_, max_age_, min_fresh_, max_stale_} + {true, true, false, false, Seconds(10), absl::nullopt, absl::nullopt} + }, + ); + // clang-format on + } +}; + +INSTANTIATE_TEST_SUITE_P(RequestCacheControlTest, RequestCacheControlTest, + testing::ValuesIn(RequestCacheControlTest::getTestCases())); + +TEST_P(RequestCacheControlTest, RequestCacheControlTest) { + const absl::string_view cache_control_header = GetParam().cache_control_header; + const RequestCacheControl expected_request_cache_control = GetParam().request_cache_control; + EXPECT_EQ(expected_request_cache_control, RequestCacheControl(cache_control_header)); +} + +// operator<<(ostream&, const RequestCacheControl&) is only used in tests, but lives in //source, +// and so needs test coverage. This test provides that coverage, to keep the coverage test happy. +TEST(RequestCacheControl, StreamingTest) { + std::ostringstream os; + RequestCacheControl request_cache_control( + "no-cache, no-store, no-transform, only-if-cached, max-age=0, min-fresh=0, max-stale=0"); + os << request_cache_control; + EXPECT_EQ(os.str(), "{must_validate, no_store, no_transform, only_if_cached, max-age=0, " + "min-fresh=0, max-stale=0}"); +} + +// operator<<(ostream&, const ResponseCacheControl&) is only used in tests, but lives in //source, +// and so needs test coverage. This test provides that coverage, to keep the coverage test happy. +TEST(ResponseCacheControl, StreamingTest) { + std::ostringstream os; + ResponseCacheControl response_cache_control( + "no-cache, must-revalidate, no-store, no-transform, max-age=0, public"); + os << response_cache_control; + EXPECT_EQ(os.str(), "{must_validate, no_store, no_transform, no_stale, public, max-age=0}"); +} + +struct TestResponseCacheControl : public ResponseCacheControl { + TestResponseCacheControl(bool must_validate, bool no_store, bool no_transform, bool no_stale, + bool is_public, OptionalDuration max_age) { + must_validate_ = must_validate; + no_store_ = no_store; + no_transform_ = no_transform; + no_stale_ = no_stale; + is_public_ = is_public; + max_age_ = max_age; + } +}; + +struct ResponseCacheControlTestCase { + absl::string_view cache_control_header; + TestResponseCacheControl response_cache_control; +}; + +class ResponseCacheControlTest : public testing::TestWithParam { +public: + static const std::vector& getTestCases() { + // clang-format off + CONSTRUCT_ON_FIRST_USE(std::vector, + // Empty header + { + "", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, false, false, false, absl::nullopt} + }, + // Valid cache-control headers + { + "s-maxage=1000, max-age=2000, proxy-revalidate, no-store", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, true, false, true, false, Seconds(1000)} + }, + { + "max-age=500, must-revalidate, no-cache, no-transform", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, false, true, true, false, Seconds(500)} + }, + { + "s-maxage=10, private=content-length, no-cache=content-encoding", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, true, false, false, false, Seconds(10)} + }, + { + "private", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, true, false, false, false, absl::nullopt} + }, + { + "public, max-age=0", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, false, false, true, Seconds(0)} + }, + // Quoted arguments are interpreted correctly + { + "s-maxage=\"20\", max-age=\"10\", public", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, false, false, true, Seconds(20)} + }, + { + "max-age=\"50\", private", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, true, false, false, false, Seconds(50)} + }, + { + "s-maxage=\"0\"", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, false, false, false, Seconds(0)} + }, + // Unknown directives are ignored + { + "private, no-cache, max-age=30, unknown-directive", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, true, false, false, false, Seconds(30)} + }, + { + "private, no-cache, max-age=30, unknown-directive-with-arg=arg", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, true, false, false, false, Seconds(30)} + }, + { + "private, no-cache, max-age=30, unknown-directive-with-quoted-arg=\"arg\"", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, true, false, false, false, Seconds(30)} + }, + { + "private, no-cache, max-age=30, unknown-directive, unknown-directive-with-quoted-arg=\"arg\"", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, true, false, false, false, Seconds(30)} + }, + // Invalid durations are ignored + { + "max-age=five", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, false, false, false, absl::nullopt} + }, + { + "max-age=10s, private", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, true, false, false, false, absl::nullopt} + }, + { + "s-maxage=\"50s\", max-age=\"zero\", no-cache", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, false, false, false, false, absl::nullopt} + }, + { + "s-maxage=five, max-age=10, no-transform", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, true, false, false, Seconds(10)} + }, + { + "max-age=\"", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {false, false, false, false, false, absl::nullopt} + }, + // Invalid parts of the header are ignored + { + "no-cache, ,,,fjfwioen3298, max-age=20", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, false, false, false, false, Seconds(20)} + }, + // If a directive argument contains a comma by mistake + // the part before the comma will be interpreted as the argument + // and the part after it will be ignored + { + "no-cache, max-age=10,0, no-store", + // {must_validate_, no_store_, no_transform_, no_stale_, is_public_, max_age_} + {true, true, false, false, false, Seconds(10)} + }, + ); + // clang-format on + } +}; + +INSTANTIATE_TEST_SUITE_P(ResponseCacheControlTest, ResponseCacheControlTest, + testing::ValuesIn(ResponseCacheControlTest::getTestCases())); + +TEST_P(ResponseCacheControlTest, ResponseCacheControlTest) { + const absl::string_view cache_control_header = GetParam().cache_control_header; + const ResponseCacheControl expected_response_cache_control = GetParam().response_cache_control; + EXPECT_EQ(expected_response_cache_control, ResponseCacheControl(cache_control_header)); +} + +class HttpTimeTest : public testing::TestWithParam { +public: + static const std::vector& getOkTestCases() { + // clang-format off + CONSTRUCT_ON_FIRST_USE(std::vector, + "Sun, 06 Nov 1994 08:49:37 GMT", // IMF-fixdate. + "Sunday, 06-Nov-94 08:49:37 GMT", // obsolete RFC 850 format. + "Sun Nov 6 08:49:37 1994" // ANSI C's asctime() format. + ); + // clang-format on + } +}; + +INSTANTIATE_TEST_SUITE_P(Ok, HttpTimeTest, testing::ValuesIn(HttpTimeTest::getOkTestCases())); + +TEST_P(HttpTimeTest, OkFormats) { + const Http::TestResponseHeaderMapImpl response_headers{{"date", GetParam()}}; + // Manually confirmed that 784111777 is 11/6/94, 8:46:37. + EXPECT_EQ(784111777, + SystemTime::clock::to_time_t(CacheHeadersUtils::httpTime(response_headers.Date()))); +} + +TEST(HttpTime, InvalidFormat) { + const std::string invalid_format_date = "Sunday, 06-11-1994 08:49:37"; + const Http::TestResponseHeaderMapImpl response_headers{{"date", invalid_format_date}}; + EXPECT_EQ(CacheHeadersUtils::httpTime(response_headers.Date()), SystemTime()); +} + +TEST(HttpTime, Null) { EXPECT_EQ(CacheHeadersUtils::httpTime(nullptr), SystemTime()); } + +struct CalculateAgeTestCase { + std::string test_name; + Http::TestResponseHeaderMapImpl response_headers; + SystemTime response_time, now; + Seconds expected_age; +}; + +class CalculateAgeTest : public testing::TestWithParam { +public: + static std::string durationToString(const SystemTime::duration& duration) { + return std::to_string(duration.count()); + } + static std::string formatTime(const SystemTime& time) { return formatter().fromTime(time); } + static const DateFormatter& formatter() { + CONSTRUCT_ON_FIRST_USE(DateFormatter, {"%a, %d %b %Y %H:%M:%S GMT"}); + } + static const SystemTime& currentTime() { + CONSTRUCT_ON_FIRST_USE(SystemTime, Event::SimulatedTimeSystem().systemTime()); + } + static const std::vector& getTestCases() { + // clang-format off + CONSTRUCT_ON_FIRST_USE(std::vector, + { + "no_initial_age_all_times_equal", + /*response_headers=*/{{"date", formatTime(currentTime())}}, + /*response_time=*/currentTime(), + /*now=*/currentTime(), + /*expected_age=*/Seconds(0) + }, + { + "initial_age_zero_all_times_equal", + /*response_headers=*/{{"date", formatTime(currentTime())}, {"age", "0"}}, + /*response_time=*/currentTime(), + /*now=*/currentTime(), + /*expected_age=*/Seconds(0) + }, + { + "initial_age_non_zero_all_times_equal", + /*response_headers=*/{{"date", formatTime(currentTime())}, {"age", "50"}}, + /*response_time=*/currentTime(), + /*now=*/currentTime(), + /*expected_age=*/Seconds(50) + }, + { + "date_after_response_time_no_initial_age", + /*response_headers=*/{{"date", formatTime(currentTime() + Seconds(5))}}, + /*response_time=*/currentTime(), + /*now=*/currentTime() + Seconds(10), + /*expected_age=*/Seconds(10) + }, + { + "date_after_response_time_with_initial_age", + /*response_headers=*/{{"date", formatTime(currentTime() + Seconds(10))}, {"age", "5"}}, + /*response_time=*/currentTime(), + /*now=*/currentTime() + Seconds(10), + /*expected_age=*/Seconds(15) + }, + { + "apparent_age_equals_initial_age", + /*response_headers=*/{{"date", formatTime(currentTime())}, {"age", "1"}}, + /*response_time=*/currentTime() + Seconds(1), + /*now=*/currentTime() + Seconds(5), + /*expected_age=*/Seconds(5) + }, + { + "apparent_age_lower_than_initial_age", + /*response_headers=*/{{"date", formatTime(currentTime())}, {"age", "3"}}, + /*response_time=*/currentTime() + Seconds(1), + /*now=*/currentTime() + Seconds(5), + /*expected_age=*/Seconds(7) + }, + { + "apparent_age_higher_than_initial_age", + /*response_headers=*/{{"date", formatTime(currentTime())}, {"age", "1"}}, + /*response_time=*/currentTime() + Seconds(3), + /*now=*/currentTime() + Seconds(5), + /*expected_age=*/Seconds(5) + }, + ); + // clang-format on + } +}; + +INSTANTIATE_TEST_SUITE_P(CalculateAgeTest, CalculateAgeTest, + testing::ValuesIn(CalculateAgeTest::getTestCases()), + [](const auto& info) { return info.param.test_name; }); + +TEST_P(CalculateAgeTest, CalculateAgeTest) { + const Seconds calculated_age = CacheHeadersUtils::calculateAge( + GetParam().response_headers, GetParam().response_time, GetParam().now); + const Seconds expected_age = GetParam().expected_age; + EXPECT_EQ(calculated_age, expected_age) + << "Expected age: " << durationToString(expected_age) + << ", Calculated age: " << durationToString(calculated_age); +} + +void testReadAndRemoveLeadingDigits(absl::string_view input, int64_t expected, + absl::string_view remaining) { + absl::string_view test_input(input); + auto output = CacheHeadersUtils::readAndRemoveLeadingDigits(test_input); + if (output) { + EXPECT_EQ(output, static_cast(expected)) << "input=" << input; + EXPECT_EQ(test_input, remaining) << "input=" << input; + } else { + EXPECT_LT(expected, 0) << "input=" << input; + EXPECT_EQ(test_input, remaining) << "input=" << input; + } +} + +TEST(ReadAndRemoveLeadingDigits, ComprehensiveTest) { + testReadAndRemoveLeadingDigits("123", 123, ""); + testReadAndRemoveLeadingDigits("a123", -1, "a123"); + testReadAndRemoveLeadingDigits("9_", 9, "_"); + testReadAndRemoveLeadingDigits("11111111111xyz", 11111111111ll, "xyz"); + + // Overflow case + testReadAndRemoveLeadingDigits("1111111111111111111111111111111xyz", -1, + "1111111111111111111111111111111xyz"); + + // 2^64 + testReadAndRemoveLeadingDigits("18446744073709551616xyz", -1, "18446744073709551616xyz"); + // 2^64-1 + testReadAndRemoveLeadingDigits("18446744073709551615xyz", 18446744073709551615ull, "xyz"); + // (2^64-1)*10+9 + testReadAndRemoveLeadingDigits("184467440737095516159yz", -1, "184467440737095516159yz"); +} + +TEST(GetAllMatchingHeaderNames, EmptyRuleset) { + Http::TestRequestHeaderMapImpl headers{{"accept", "image/*"}}; + std::vector ruleset; + absl::flat_hash_set result; + + CacheHeadersUtils::getAllMatchingHeaderNames(headers, ruleset, result); + + EXPECT_TRUE(result.empty()); +} + +TEST(GetAllMatchingHeaderNames, EmptyHeaderMap) { + NiceMock context; + Http::TestRequestHeaderMapImpl headers; + std::vector ruleset; + absl::flat_hash_set result; + + envoy::type::matcher::v3::StringMatcher matcher; + matcher.set_exact("accept"); + ruleset.emplace_back(std::make_unique(matcher, context)); + + CacheHeadersUtils::getAllMatchingHeaderNames(headers, ruleset, result); + + EXPECT_TRUE(result.empty()); +} + +TEST(GetAllMatchingHeaderNames, SingleMatchSingleValue) { + NiceMock context; + Http::TestRequestHeaderMapImpl headers{{"accept", "image/*"}, {"accept-language", "en-US"}}; + std::vector ruleset; + absl::flat_hash_set result; + + envoy::type::matcher::v3::StringMatcher matcher; + matcher.set_exact("accept"); + ruleset.emplace_back(std::make_unique(matcher, context)); + + CacheHeadersUtils::getAllMatchingHeaderNames(headers, ruleset, result); + + ASSERT_EQ(result.size(), 1); + EXPECT_TRUE(result.contains("accept")); +} + +TEST(GetAllMatchingHeaderNames, SingleMatchMultiValue) { + NiceMock context; + Http::TestRequestHeaderMapImpl headers{{"accept", "image/*"}, {"accept", "text/html"}}; + std::vector ruleset; + absl::flat_hash_set result; + + envoy::type::matcher::v3::StringMatcher matcher; + matcher.set_exact("accept"); + ruleset.emplace_back(std::make_unique(matcher, context)); + + CacheHeadersUtils::getAllMatchingHeaderNames(headers, ruleset, result); + + ASSERT_EQ(result.size(), 1); + EXPECT_TRUE(result.contains("accept")); +} + +TEST(GetAllMatchingHeaderNames, MultipleMatches) { + NiceMock context; + Http::TestRequestHeaderMapImpl headers{{"accept", "image/*"}, {"accept-language", "en-US"}}; + std::vector ruleset; + absl::flat_hash_set result; + + envoy::type::matcher::v3::StringMatcher matcher; + matcher.set_exact("accept"); + ruleset.emplace_back(std::make_unique(matcher, context)); + matcher.set_exact("accept-language"); + ruleset.emplace_back(std::make_unique(matcher, context)); + + CacheHeadersUtils::getAllMatchingHeaderNames(headers, ruleset, result); + + ASSERT_EQ(result.size(), 2); + EXPECT_TRUE(result.contains("accept")); + EXPECT_TRUE(result.contains("accept-language")); +} + +struct ParseCommaDelimitedHeaderTestCase { + absl::string_view name; + std::vector header_entries; + std::vector expected_values; +}; + +std::string getParseCommaDelimitedHeaderTestName( + const testing::TestParamInfo& info) { + return std::string(info.param.name); +} + +std::vector parseCommaDelimitedHeaderTestParams() { + return { + { + "Null", + {}, + {}, + }, + { + "Empty", + {}, + {}, + }, + { + "SingleValue", + {"accept"}, + {"accept"}, + }, + { + "MultiValue", + {"accept,accept-language"}, + {"accept", "accept-language"}, + }, + { + "MultiValueLeadingSpace", + {" accept,accept-language"}, + {"accept", "accept-language"}, + }, + { + "MultiValueSpaceAfterValue", + {"accept ,accept-language"}, + {"accept", "accept-language"}, + }, + { + "MultiValueTrailingSpace", + {"accept,accept-language "}, + {"accept", "accept-language"}, + }, + { + "MultiValueLotsOfSpaces", + {" accept , accept-language "}, + {"accept", "accept-language"}, + }, + { + "MultiEntry", + {"accept", "accept-language"}, + {"accept", "accept-language"}, + }, + { + "MultiEntryMultiValue", + {"accept,accept-language", "foo,bar"}, + {"accept", "accept-language", "foo", "bar"}, + }, + { + "MultiEntryMultiValueWithSpaces", + {"accept, accept-language ", "foo ,bar"}, + {"accept", "accept-language", "foo", "bar"}, + }, + }; +} + +class ParseCommaDelimitedHeaderTest + : public testing::TestWithParam {}; + +INSTANTIATE_TEST_SUITE_P(ParseCommaDelimitedHeaderTest, ParseCommaDelimitedHeaderTest, + testing::ValuesIn(parseCommaDelimitedHeaderTestParams()), + getParseCommaDelimitedHeaderTestName); + +TEST_P(ParseCommaDelimitedHeaderTest, ParseCommaDelimitedHeader) { + ParseCommaDelimitedHeaderTestCase test_case = GetParam(); + const Http::LowerCaseString header_name = Http::CustomHeaders::get().Vary; + Http::TestResponseHeaderMapImpl headers; + for (absl::string_view entry : test_case.header_entries) { + headers.addCopy(header_name, entry); + } + std::vector result = + CacheHeadersUtils::parseCommaDelimitedHeader(headers.get(header_name)); + std::vector expected(test_case.expected_values.begin(), + test_case.expected_values.end()); + EXPECT_EQ(result, expected); +} + +TEST(CreateVaryIdentifier, IsStableForAllowListOrder) { + NiceMock factory_context; + VaryAllowList vary_allow_list1(toStringMatchers({"width", "accept", "accept-language"}), + factory_context); + VaryAllowList vary_allow_list2(toStringMatchers({"accept", "width", "accept-language"}), + factory_context); + + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "image/*"}, {"accept-language", "en-us"}, {"width", "640"}}; + + absl::optional vary_identifier1 = VaryHeaderUtils::createVaryIdentifier( + vary_allow_list1, {"accept", "accept-language", "", "width"}, request_headers); + absl::optional vary_identifier2 = VaryHeaderUtils::createVaryIdentifier( + vary_allow_list2, {"accept", "accept-language", "width"}, request_headers); + + ASSERT_TRUE(vary_identifier1.has_value()); + ASSERT_TRUE(vary_identifier2.has_value()); + EXPECT_EQ(vary_identifier1.value(), vary_identifier2.value()); +} + +TEST(GetVaryValues, noVary) { + Http::TestResponseHeaderMapImpl headers; + EXPECT_EQ(0, VaryHeaderUtils::getVaryValues(headers).size()); +} + +TEST(GetVaryValues, emptyVary) { + Http::TestResponseHeaderMapImpl headers{{"vary", ""}}; + EXPECT_EQ(0, VaryHeaderUtils::getVaryValues(headers).size()); +} + +TEST(GetVaryValues, singleVary) { + Http::TestResponseHeaderMapImpl headers{{"vary", "accept"}}; + absl::btree_set result_set = VaryHeaderUtils::getVaryValues(headers); + std::vector result(result_set.begin(), result_set.end()); + std::vector expected = {"accept"}; + EXPECT_EQ(expected, result); +} + +TEST(GetVaryValues, multipleVaryAllowLists) { + Http::TestResponseHeaderMapImpl headers{{"vary", "accept"}, {"vary", "origin"}}; + absl::btree_set result_set = VaryHeaderUtils::getVaryValues(headers); + std::vector result(result_set.begin(), result_set.end()); + std::vector expected = {"accept", "origin"}; + EXPECT_EQ(expected, result); +} + +TEST(HasVary, Null) { + Http::TestResponseHeaderMapImpl headers; + EXPECT_FALSE(VaryHeaderUtils::hasVary(headers)); +} + +TEST(HasVary, Empty) { + Http::TestResponseHeaderMapImpl headers{{"vary", ""}}; + EXPECT_FALSE(VaryHeaderUtils::hasVary(headers)); +} + +TEST(HasVary, NotEmpty) { + Http::TestResponseHeaderMapImpl headers{{"vary", "accept"}}; + EXPECT_TRUE(VaryHeaderUtils::hasVary(headers)); +} + +TEST(CreateVaryIdentifier, EmptyVaryEntry) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{{"accept", "image/*"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {}, request_headers), + "vary-id\n"); +} + +TEST(CreateVaryIdentifier, SingleHeaderExists) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{{"accept", "image/*"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {"accept"}, request_headers), + "vary-id\naccept\r" + "image/*\n"); +} + +TEST(CreateVaryIdentifier, SingleHeaderMissing) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {"accept"}, request_headers), + "vary-id\naccept\r\n"); +} + +TEST(CreateVaryIdentifier, MultipleHeadersAllExist) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "image/*"}, {"accept-language", "en-us"}, {"width", "640"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier( + vary_allow_list, {"accept", "accept-language", "width"}, request_headers), + "vary-id\naccept\r" + "image/*\naccept-language\r" + "en-us\nwidth\r640\n"); +} + +TEST(CreateVaryIdentifier, MultipleHeadersSomeExist) { + NiceMock factory_context; + Http::TestResponseHeaderMapImpl response_headers{{"vary", "accept, accept-language, width"}}; + Http::TestRequestHeaderMapImpl request_headers{{"accept", "image/*"}, {"width", "640"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier( + vary_allow_list, {"accept", "accept-language", "width"}, request_headers), + "vary-id\naccept\r" + "image/*\naccept-language\r\nwidth\r640\n"); +} + +TEST(CreateVaryIdentifier, ExtraRequestHeaders) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{ + {"accept", "image/*"}, {"heigth", "1280"}, {"width", "640"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ( + VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {"accept", "width"}, request_headers), + "vary-id\naccept\r" + "image/*\nwidth\r640\n"); +} + +TEST(CreateVaryIdentifier, MultipleHeadersNoneExist) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier( + vary_allow_list, {"accept", "accept-language", "width"}, request_headers), + "vary-id\naccept\r\naccept-language\r\nwidth\r\n"); +} + +TEST(CreateVaryIdentifier, DifferentHeadersSameValue) { + NiceMock factory_context; + + // Two requests with the same value for different headers must have different + // vary-ids. + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + Http::TestRequestHeaderMapImpl request_headers1{{"accept", "foo"}}; + absl::optional vary_identifier1 = VaryHeaderUtils::createVaryIdentifier( + vary_allow_list, {"accept", "accept-language"}, request_headers1); + + Http::TestRequestHeaderMapImpl request_headers2{{"accept-language", "foo"}}; + absl::optional vary_identifier2 = VaryHeaderUtils::createVaryIdentifier( + vary_allow_list, {"accept", "accept-language", "width"}, request_headers2); + + ASSERT_TRUE(vary_identifier1.has_value()); + ASSERT_TRUE(vary_identifier2.has_value()); + EXPECT_NE(vary_identifier1.value(), vary_identifier2.value()); +} + +TEST(CreateVaryIdentifier, MultiValueSameHeader) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{{"width", "foo"}, {"width", "bar"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {"width"}, request_headers), + "vary-id\nwidth\r" + "foo\r" + "bar\n"); +} + +TEST(CreateVaryIdentifier, DisallowedHeader) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{{"width", "foo"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ(VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {"disallowed"}, request_headers), + absl::nullopt); +} + +TEST(CreateVaryIdentifier, DisallowedHeaderWithAllowedHeader) { + NiceMock factory_context; + Http::TestRequestHeaderMapImpl request_headers{{"width", "foo"}}; + VaryAllowList vary_allow_list(toStringMatchers({"accept", "accept-language", "width"}), + factory_context); + + EXPECT_EQ( + VaryHeaderUtils::createVaryIdentifier(vary_allow_list, {"disallowed,width"}, request_headers), + absl::nullopt); +} + +envoy::extensions::filters::http::cache_v2::v3::CacheV2Config getConfig() { + // Allows {accept, accept-language, width} to be varied in the tests. + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config config; + + const auto& add_accept = config.mutable_allowed_vary_headers()->Add(); + add_accept->set_exact("accept"); + + const auto& add_accept_language = config.mutable_allowed_vary_headers()->Add(); + add_accept_language->set_exact("accept-language"); + + const auto& add_width = config.mutable_allowed_vary_headers()->Add(); + add_width->set_exact("width"); + + return config; +} + +class VaryAllowListTest : public testing::Test { +protected: + VaryAllowListTest() : vary_allow_list_(getConfig().allowed_vary_headers(), factory_context_) {} + + NiceMock factory_context_; + VaryAllowList vary_allow_list_; + Http::TestRequestHeaderMapImpl request_headers_; + Http::TestResponseHeaderMapImpl response_headers_; +}; + +TEST_F(VaryAllowListTest, AllowsHeaderAccept) { + EXPECT_TRUE(vary_allow_list_.allowsValue("accept")); +} + +TEST_F(VaryAllowListTest, AllowsHeaderWrongHeader) { + EXPECT_FALSE(vary_allow_list_.allowsValue("wrong-header")); +} + +TEST_F(VaryAllowListTest, AllowsHeaderEmpty) { EXPECT_FALSE(vary_allow_list_.allowsValue("")); } + +TEST_F(VaryAllowListTest, AllowsHeadersNull) { + EXPECT_TRUE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST_F(VaryAllowListTest, AllowsHeadersEmpty) { + response_headers_.addCopy("vary", ""); + EXPECT_TRUE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST_F(VaryAllowListTest, AllowsHeadersSingle) { + response_headers_.addCopy("vary", "accept"); + EXPECT_TRUE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST_F(VaryAllowListTest, AllowsHeadersMultiple) { + response_headers_.addCopy("vary", "accept"); + EXPECT_TRUE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST_F(VaryAllowListTest, NotAllowsHeadersStar) { + // Should never be allowed, regardless of the allow_list. + response_headers_.addCopy("vary", "*"); + EXPECT_FALSE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST_F(VaryAllowListTest, NotAllowsHeadersSingle) { + response_headers_.addCopy("vary", "wrong-header"); + EXPECT_FALSE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST_F(VaryAllowListTest, NotAllowsHeadersMixed) { + response_headers_.addCopy("vary", "accept, wrong-header"); + EXPECT_FALSE(vary_allow_list_.allowsHeaders(response_headers_)); +} + +TEST(InjectValidationHeaders, InjectsIfModifiedSince) { + Http::TestResponseHeaderMapImpl old_response_headers; + constexpr absl::string_view mod_time = "Fri, 01 Aug 2025 09:25:10 GMT"; + old_response_headers.setInline(CacheCustomHeaders::lastModified(), mod_time); + Http::TestRequestHeaderMapImpl request_headers; + CacheHeadersUtils::injectValidationHeaders(request_headers, old_response_headers); + EXPECT_THAT(request_headers, ContainsHeader("if-modified-since", mod_time)); +} + +TEST(ShouldUpdateCachedEntry, ComparesEtags) { + Http::TestResponseHeaderMapImpl old_headers, new_headers; + old_headers.setStatus(304); + new_headers.setStatus(304); + old_headers.setInline(CacheCustomHeaders::etag(), "abc"); + new_headers.setInline(CacheCustomHeaders::etag(), "abc"); + EXPECT_TRUE(CacheHeadersUtils::shouldUpdateCachedEntry(new_headers, old_headers)); + new_headers.setInline(CacheCustomHeaders::etag(), "def"); + EXPECT_FALSE(CacheHeadersUtils::shouldUpdateCachedEntry(new_headers, old_headers)); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/cache_sessions_test.cc b/test/extensions/filters/http/cache_v2/cache_sessions_test.cc new file mode 100644 index 0000000000000..ea80ad1d85796 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cache_sessions_test.cc @@ -0,0 +1,851 @@ +#include + +#include "envoy/event/dispatcher.h" + +#include "source/common/http/headers.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" + +#include "test/extensions/filters/http/cache_v2/mocks.h" +#include "test/mocks/http/mocks.h" +#include "test/mocks/server/factory_context.h" +#include "test/test_common/logging.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +using ::testing::_; +using ::testing::AllOf; +using ::testing::AnyNumber; +using ::testing::Between; +using ::testing::ElementsAre; +using ::testing::Eq; +using ::testing::ExplainMatchResult; +using ::testing::IsEmpty; +using ::testing::IsNull; +using ::testing::Mock; +using ::testing::MockFunction; +using ::testing::NotNull; +using ::testing::Pointee; +using ::testing::Property; +using ::testing::Return; + +template T consumeCallback(T& cb) { + T ret = std::move(cb); + cb = nullptr; + return ret; +} + +class CacheSessionsTest : public ::testing::Test { +protected: + Event::SimulatedTimeSystem time_system_; + Api::ApiPtr api_ = Api::createApiForTest(); + Event::DispatcherPtr dispatcher_ = api_->allocateDispatcher("test_thread"); + std::shared_ptr cache_sessions_; + MockHttpCache* mock_http_cache_; + Http::MockAsyncClient mock_async_client_; + std::vector captured_lookup_callbacks_; + std::vector fake_upstreams_; + std::vector fake_upstream_sent_headers_; + std::vector fake_upstream_get_headers_callbacks_; + std::shared_ptr mock_cacheable_response_checker_ = + std::make_shared(); + testing::NiceMock mock_factory_context_; + + void advanceTime(std::chrono::milliseconds increment) { + SystemTime current_time = time_system_.systemTime(); + current_time += increment; + time_system_.setSystemTime(current_time); + } + + void SetUp() override { + EXPECT_CALL(*mock_cacheable_response_checker_, isCacheableResponse) + .Times(AnyNumber()) + .WillRepeatedly(Return(true)); + auto mock_http_cache = std::make_unique(); + mock_http_cache_ = mock_http_cache.get(); + cache_sessions_ = CacheSessions::create(mock_factory_context_, std::move(mock_http_cache)); + ON_CALL(*mock_http_cache_, lookup) + .WillByDefault([this](LookupRequest&&, HttpCache::LookupCallback&& cb) { + captured_lookup_callbacks_.push_back(std::move(cb)); + }); + } + + void pumpDispatcher() { dispatcher_->run(Event::Dispatcher::RunType::Block); } + + void TearDown() override { + pumpDispatcher(); + // Any residual cache lookups must complete their callbacks to close + // out ownership of the CacheSessionsEntries. + for (auto& cb : captured_lookup_callbacks_) { + if (cb) { + // Cache entries will be evicted when cache returns an error for lookup. + EXPECT_CALL(*mock_http_cache_, evict); + consumeCallback(cb)(absl::UnknownError("test teardown")); + pumpDispatcher(); + } + } + // Any residual upstreams must complete their callbacks to close out + // ownership of the CacheSessionsEntries. + for (auto& cb : fake_upstream_get_headers_callbacks_) { + if (cb) { + consumeCallback(cb)(nullptr, EndStream::Reset); + pumpDispatcher(); + } + } + } + + UpstreamRequestFactoryPtr mockUpstreamFactory() { + auto factory = std::make_unique(); + EXPECT_CALL(*factory, create).WillRepeatedly([this]() -> UpstreamRequestPtr { + auto upstream_request = std::make_unique(); + fake_upstreams_.emplace_back(upstream_request.get()); + fake_upstream_sent_headers_.push_back(nullptr); + fake_upstream_get_headers_callbacks_.push_back(nullptr); + // We can't capture the callback inside the FakeUpstream because that + // causes an ownership cycle. + int i = fake_upstreams_.size() - 1; + EXPECT_CALL(*upstream_request, sendHeaders) + .WillOnce([this, i](Http::RequestHeaderMapPtr headers) { + fake_upstream_sent_headers_[i] = std::move(headers); + }); + EXPECT_CALL(*upstream_request, getHeaders) + .Times(Between(0, 1)) + .WillRepeatedly([this, i](GetHeadersCallback&& cb) { + fake_upstream_get_headers_callbacks_[i] = std::move(cb); + }); + return upstream_request; + }); + return factory; + } + + Http::TestRequestHeaderMapImpl requestHeaders(absl::string_view path) { + return Http::TestRequestHeaderMapImpl{ + {"host", "test_host"}, {":path", std::string{path}}, {":scheme", "https"}}; + } + + ActiveLookupRequestPtr testLookupRequest(Http::RequestHeaderMap& headers) { + return std::make_unique( + headers, mockUpstreamFactory(), "test_cluster", *dispatcher_, + api_->timeSource().systemTime(), mock_cacheable_response_checker_, cache_sessions_, false); + } + + ActiveLookupRequestPtr testLookupRequest(absl::string_view path) { + auto headers = requestHeaders(path); + return testLookupRequest(headers); + } + + ActiveLookupRequestPtr testLookupRangeRequest(absl::string_view path, int start, int end) { + auto headers = requestHeaders(path); + headers.addCopy("range", absl::StrCat("bytes=", start, "-", end)); + return testLookupRequest(headers); + } + + ActiveLookupRequestPtr testLookupRequestWithNoCache(absl::string_view path) { + auto headers = requestHeaders(path); + headers.addCopy("cache-control", "no-cache"); + return testLookupRequest(headers); + } +}; + +Http::ResponseHeaderMapPtr uncacheableResponseHeaders() { + auto h = std::make_unique(); + h->addCopy("cache-control", "no-cache"); + return h; +} + +static std::string dateNow() { + static const DateFormatter formatter{"%a, %d %b %Y %H:%M:%S GMT"}; + SystemTime now = Event::SimulatedTimeSystem().systemTime(); + return formatter.fromTime(now); +} + +static std::string dateNowPlus60s() { + static const DateFormatter formatter{"%a, %d %b %Y %H:%M:%S GMT"}; + SystemTime t = Event::SimulatedTimeSystem().systemTime(); + t += std::chrono::seconds(60); + return formatter.fromTime(t); +} + +Http::ResponseHeaderMapPtr cacheableResponseHeaders(absl::optional content_length = 0) { + auto h = std::make_unique(); + h->setStatus("200"); + h->addCopy(":scheme", "http"); + h->addCopy(":method", "GET"); + h->addCopy("cache-control", "max-age=86400"); + h->addCopy("date", dateNow()); + if (content_length.has_value()) { + h->addCopy("content-length", absl::StrCat(content_length.value())); + } + return h; +} + +Http::ResponseHeaderMapPtr +cacheableResponseHeadersByExpire(absl::optional content_length = 0) { + auto h = std::make_unique(); + h->setStatus("200"); + h->addCopy(":scheme", "http"); + h->addCopy(":method", "GET"); + h->addCopy("expires", dateNowPlus60s()); + h->addCopy("date", dateNow()); + if (content_length.has_value()) { + h->addCopy("content-length", absl::StrCat(content_length.value())); + } + return h; +} + +inline constexpr auto KeyHasPath = [](const auto& m) { return Property("path", &Key::path, m); }; + +inline constexpr auto LookupHasKey = [](const auto& m) { + return Property("key", &LookupRequest::key, m); +}; + +inline constexpr auto LookupHasPath = [](const auto& m) { return LookupHasKey(KeyHasPath(m)); }; + +inline constexpr auto RangeIs = [](const auto& m1, const auto& m2) { + return AllOf(Property("begin", &AdjustedByteRange::begin, m1), + Property("end", &AdjustedByteRange::end, m2)); +}; + +MATCHER_P(HasNoHeader, key, "") { + *result_listener << arg; + return ExplainMatchResult(IsEmpty(), arg.get(::Envoy::Http::LowerCaseString(std::string(key))), + result_listener); +} + +MATCHER_P(GetResultHasValue, matcher, "") { + if (!ExplainMatchResult(Property("size", &Http::HeaderMap::GetResult::size, 1), arg, + result_listener)) { + return false; + } + return ExplainMatchResult(matcher, arg[0]->value().getStringView(), result_listener); +} + +MATCHER_P2(HasHeader, key, matcher, "") { + *result_listener << arg; + return ExplainMatchResult(GetResultHasValue(matcher), + arg.get(::Envoy::Http::LowerCaseString(std::string(key))), + result_listener); +} + +TEST_F(CacheSessionsTest, RequestsForSeparateKeysIssueSeparateLookupRequests) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/b"), _)); + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/c"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/b"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/c"), _)); + cache_sessions_->lookup(testLookupRequest("/a"), [](ActiveLookupResultPtr) {}); + cache_sessions_->lookup(testLookupRequest("/b"), [](ActiveLookupResultPtr) {}); + cache_sessions_->lookup(testLookupRequest("/c"), [](ActiveLookupResultPtr) {}); + pumpDispatcher(); + EXPECT_THAT(captured_lookup_callbacks_.size(), Eq(3)); +} + +TEST_F(CacheSessionsTest, MultipleRequestsForSameKeyIssuesOnlyOneLookupRequest) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(3); + cache_sessions_->lookup(testLookupRequest("/a"), [](ActiveLookupResultPtr) {}); + cache_sessions_->lookup(testLookupRequest("/a"), [](ActiveLookupResultPtr) {}); + cache_sessions_->lookup(testLookupRequest("/a"), [](ActiveLookupResultPtr) {}); + pumpDispatcher(); + EXPECT_THAT(captured_lookup_callbacks_.size(), Eq(1)); +} + +TEST_F(CacheSessionsTest, CacheSessionsEntriesExpireOnAdjacentLookup) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)).Times(2); + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/b"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(2); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/b"), _)); + cache_sessions_->lookup(testLookupRequest("/a"), [](ActiveLookupResultPtr) {}); + advanceTime(std::chrono::hours(1)); + // request to adjacent resource to trigger expiry of original. + cache_sessions_->lookup(testLookupRequest("/b"), [](ActiveLookupResultPtr) {}); + // another request for the original resource should have a new lookup because + // the old entry should have been removed. + cache_sessions_->lookup(testLookupRequest("/a"), [](ActiveLookupResultPtr) {}); + pumpDispatcher(); + EXPECT_THAT(captured_lookup_callbacks_.size(), Eq(3)); +} + +TEST_F(CacheSessionsTest, CacheDeletionDuringLookupStillCompletesLookup) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, evict(_, KeyHasPath("/a"))); + ActiveLookupResultPtr result; + cache_sessions_->lookup(testLookupRequest("/a"), + [&result](ActiveLookupResultPtr r) { result = std::move(r); }); + // cache gets deleted before lookup callback. + cache_sessions_.reset(); + pumpDispatcher(); + consumeCallback(captured_lookup_callbacks_[0])(absl::UnknownError("cache fail")); + pumpDispatcher(); + ASSERT_THAT(result, NotNull()); + EXPECT_THAT(result->status_, Eq(CacheEntryStatus::LookupError)); + // Should have become an upstream pass-through request. + EXPECT_THAT(result->http_source_.get(), Eq(fake_upstreams_[0])); +} + +TEST_F(CacheSessionsTest, CacheMissWithUncacheableResponseProvokesPassThrough) { + Mock::VerifyAndClearExpectations(mock_cacheable_response_checker_.get()); + EXPECT_CALL(*mock_cacheable_response_checker_, isCacheableResponse) + .Times(testing::AnyNumber()) + .WillRepeatedly(testing::Return(false)); + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(3); + ActiveLookupResultPtr result1, result2, result3; + cache_sessions_->lookup(testLookupRequest("/a"), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result2](ActiveLookupResultPtr r) { result2 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}))); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(1)); + pumpDispatcher(); + consumeCallback(fake_upstream_get_headers_callbacks_[0])(uncacheableResponseHeaders(), + EndStream::End); + pumpDispatcher(); + // Uncacheable should have provoked one passthrough upstream request, and + // given the already existing upstream request to the first result. + ASSERT_THAT(fake_upstreams_.size(), Eq(2)); + EXPECT_THAT(fake_upstream_sent_headers_[1], + Pointee(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}))); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(2)); + // getHeaders should not have been called yet on the second upstream, because + // that one is handed to the client unused. + EXPECT_THAT(fake_upstream_get_headers_callbacks_[1], IsNull()); + Http::ResponseHeaderMapPtr headers1, headers2, headers3; + EXPECT_THAT(result1->status_, Eq(CacheEntryStatus::Uncacheable)); + EXPECT_THAT(result2->status_, Eq(CacheEntryStatus::Uncacheable)); + // First getHeaders should be retrieving the wrapped already-captured headers from + // the original upstream. + result1->http_source_->getHeaders( + [&headers1](Http::ResponseHeaderMapPtr h, EndStream) { headers1 = std::move(h); }); + // Second one should call the upstream, so now we have a captured callback. + result2->http_source_->getHeaders( + [&headers2](Http::ResponseHeaderMapPtr h, EndStream) { headers2 = std::move(h); }); + ASSERT_THAT(fake_upstream_get_headers_callbacks_[1], NotNull()); + consumeCallback(fake_upstream_get_headers_callbacks_[1])(uncacheableResponseHeaders(), + EndStream::End); + pumpDispatcher(); + EXPECT_THAT(headers1, Pointee(IsSupersetOfHeaders( + Http::TestResponseHeaderMapImpl{{"cache-control", "no-cache"}}))); + EXPECT_THAT(headers2, Pointee(IsSupersetOfHeaders( + Http::TestResponseHeaderMapImpl{{"cache-control", "no-cache"}}))); + // Finally, a subsequent request should also be pass-through with no lookup required. + cache_sessions_->lookup(testLookupRequest("/a"), + [&result3](ActiveLookupResultPtr r) { result3 = std::move(r); }); + pumpDispatcher(); + ASSERT_THAT(result3, NotNull()); + EXPECT_THAT(result3->status_, Eq(CacheEntryStatus::Uncacheable)); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(3)); + result3->http_source_->getHeaders( + [&headers3](Http::ResponseHeaderMapPtr h, EndStream) { headers3 = std::move(h); }); + consumeCallback(fake_upstream_get_headers_callbacks_[2])(uncacheableResponseHeaders(), + EndStream::End); + pumpDispatcher(); + EXPECT_THAT(headers3, Pointee(IsSupersetOfHeaders( + Http::TestResponseHeaderMapImpl{{"cache-control", "no-cache"}}))); +} + +TEST_F(CacheSessionsTest, CacheMissWithCacheableResponseProvokesSharedInsertStream) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(3); + ActiveLookupResultPtr result1, result2, result3; + auto response_headers = cacheableResponseHeaders(); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result2](ActiveLookupResultPtr r) { result2 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}))); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(1)); + std::shared_ptr progress; + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, IsNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + ASSERT_THAT(progress, NotNull()); + progress->onHeadersInserted(std::make_unique(), + Http::createHeaderMap(*response_headers), + true); + pumpDispatcher(); + ASSERT_THAT(result1, NotNull()); + // First result should be cache miss because it triggered insertion. + EXPECT_THAT(result1->status_, Eq(CacheEntryStatus::Miss)); + ASSERT_THAT(result2, NotNull()); + // Second result should be a follower from the insertion. + EXPECT_THAT(result2->status_, Eq(CacheEntryStatus::Follower)); + // Request after insert is complete should be able to lookup immediately. + cache_sessions_->lookup(testLookupRequest("/a"), + [&result3](ActiveLookupResultPtr r) { result3 = std::move(r); }); + pumpDispatcher(); + ASSERT_THAT(result3, NotNull()); + EXPECT_THAT(result3->status_, Eq(CacheEntryStatus::Hit)); + // And get headers immediately too. + Http::ResponseHeaderMapPtr headers3; + EndStream end_stream; + result3->http_source_->getHeaders([&](Http::ResponseHeaderMapPtr headers, EndStream es) { + headers3 = std::move(headers); + end_stream = es; + }); + EXPECT_THAT(headers3, Pointee(IsSupersetOfHeaders(*response_headers))); + EXPECT_THAT(end_stream, Eq(EndStream::End)); +} + +TEST_F(CacheSessionsTest, + CacheMissWithCacheableResponseProvokesSharedInsertStreamWithBodyAndTrailers) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(3); + ActiveLookupResultPtr result1, result2, result3; + auto response_headers = cacheableResponseHeaders(); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result2](ActiveLookupResultPtr r) { result2 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}))); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(1)); + std::shared_ptr progress; + MockCacheReader* mock_cache_reader; + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, NotNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::More); + pumpDispatcher(); + // The upstream was given to the cache; since it's a fake we can forget about + // that and just have the cache complete its write operations when we choose. + ASSERT_THAT(progress, NotNull()); + { + auto m = std::make_unique(); + mock_cache_reader = m.get(); + progress->onHeadersInserted( + std::move(m), Http::createHeaderMap(*response_headers), false); + } + pumpDispatcher(); + ASSERT_THAT(result1, NotNull()); + // First result should be cache miss because it triggered insertion. + EXPECT_THAT(result1->status_, Eq(CacheEntryStatus::Miss)); + ASSERT_THAT(result2, NotNull()); + // Second result should be a follower from the existing insertion. + EXPECT_THAT(result2->status_, Eq(CacheEntryStatus::Follower)); + // Request after header-insert is complete should be able to lookup immediately. + cache_sessions_->lookup(testLookupRequest("/a"), + [&result3](ActiveLookupResultPtr r) { result3 = std::move(r); }); + pumpDispatcher(); + ASSERT_THAT(result3, NotNull()); + EXPECT_THAT(result3->status_, Eq(CacheEntryStatus::Hit)); + // And get headers immediately too. + Http::ResponseHeaderMapPtr headers3; + EndStream end_stream; + result3->http_source_->getHeaders([&](Http::ResponseHeaderMapPtr headers, EndStream es) { + headers3 = std::move(headers); + end_stream = es; + }); + pumpDispatcher(); + EXPECT_THAT(headers3, Pointee(IsSupersetOfHeaders(*response_headers))); + EXPECT_THAT(end_stream, Eq(EndStream::More)); + MockFunction body_callback1, body_callback2, body_callback3; + result1->http_source_->getBody(AdjustedByteRange(0, 5), body_callback1.AsStdFunction()); + result2->http_source_->getBody(AdjustedByteRange(0, 2), body_callback2.AsStdFunction()); + result3->http_source_->getBody(AdjustedByteRange(1, 5), body_callback3.AsStdFunction()); + EXPECT_CALL(*mock_cache_reader, getBody(_, RangeIs(0, 3), _)) + .WillOnce([&](Event::Dispatcher&, AdjustedByteRange, GetBodyCallback&& cb) { + cb(std::make_unique("abc"), EndStream::More); + }); + EXPECT_CALL(body_callback1, Call(Pointee(BufferStringEqual("abc")), EndStream::More)); + EXPECT_CALL(body_callback2, Call(Pointee(BufferStringEqual("ab")), EndStream::More)); + EXPECT_CALL(body_callback3, Call(Pointee(BufferStringEqual("bc")), EndStream::More)); + progress->onBodyInserted(AdjustedByteRange(0, 3), false); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_cache_reader); + Mock::VerifyAndClearExpectations(&body_callback1); + Mock::VerifyAndClearExpectations(&body_callback2); + Mock::VerifyAndClearExpectations(&body_callback3); + MockFunction body_callback4, body_callback5, body_callback6; + result1->http_source_->getBody(AdjustedByteRange(3, 5), body_callback4.AsStdFunction()); + result2->http_source_->getBody(AdjustedByteRange(3, 5), body_callback5.AsStdFunction()); + // Issuing a request for body that's in the cache, while other requests are still awaiting + // body that is not yet in the cache, should skip the queue. + EXPECT_CALL(*mock_cache_reader, getBody(_, RangeIs(0, 3), _)) + .WillOnce([&](Event::Dispatcher&, AdjustedByteRange, GetBodyCallback&& cb) { + cb(std::make_unique("abc"), EndStream::More); + }); + EXPECT_CALL(body_callback6, Call(Pointee(BufferStringEqual("abc")), EndStream::More)); + result3->http_source_->getBody(AdjustedByteRange(0, 3), body_callback6.AsStdFunction()); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(&body_callback6); + Mock::VerifyAndClearExpectations(mock_cache_reader); + // Finally, insert completing should post to the queued requests. + EXPECT_CALL(*mock_cache_reader, getBody(_, RangeIs(3, 5), _)) + .WillOnce([&](Event::Dispatcher&, AdjustedByteRange, GetBodyCallback&& cb) { + cb(std::make_unique("de"), EndStream::More); + }); + EXPECT_CALL(body_callback4, Call(Pointee(BufferStringEqual("de")), EndStream::More)); + EXPECT_CALL(body_callback5, Call(Pointee(BufferStringEqual("de")), EndStream::More)); + progress->onBodyInserted(AdjustedByteRange(3, 5), false); + pumpDispatcher(); + Http::TestResponseTrailerMapImpl trailers{{"x-test", "yes"}}; + MockFunction trailers_callback1, trailers_callback2; + result1->http_source_->getTrailers(trailers_callback1.AsStdFunction()); + pumpDispatcher(); + EXPECT_CALL(trailers_callback1, Call(Pointee(IsSupersetOfHeaders(trailers)), EndStream::End)); + progress->onTrailersInserted(Http::createHeaderMap(trailers)); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(&trailers_callback1); + EXPECT_CALL(trailers_callback2, Call(Pointee(IsSupersetOfHeaders(trailers)), EndStream::End)); + result2->http_source_->getTrailers(trailers_callback2.AsStdFunction()); + pumpDispatcher(); +} + +TEST_F(CacheSessionsTest, CacheHitGoesDirectlyToCachedResponses) { + auto response_headers = cacheableResponseHeaders(); + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)); + ActiveLookupResultPtr result; + cache_sessions_->lookup(testLookupRequest("/a"), + [&result](ActiveLookupResultPtr r) { result = std::move(r); }); + pumpDispatcher(); + MockCacheReader* mock_cache_reader; + // Cache hit. + Http::TestResponseTrailerMapImpl response_trailers{{"x-test", "yes"}}; + { + auto m = std::make_unique(); + mock_cache_reader = m.get(); + ResponseMetadata metadata; + metadata.response_time_ = api_->timeSource().systemTime(); + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{ + std::move(m), + Http::createHeaderMap(*response_headers), + Http::createHeaderMap(response_trailers), + std::move(metadata), + 5, + }); + } + pumpDispatcher(); + EXPECT_THAT(result->status_, Eq(CacheEntryStatus::Hit)); + MockFunction header_callback; + EXPECT_CALL(header_callback, + Call(Pointee(IsSupersetOfHeaders(*response_headers)), EndStream::More)); + result->http_source_->getHeaders(header_callback.AsStdFunction()); + MockFunction body_callback1, body_callback2; + EXPECT_CALL(body_callback1, Call(Pointee(BufferStringEqual("abcde")), EndStream::More)); + EXPECT_CALL(*mock_cache_reader, getBody(_, RangeIs(0, 5), _)) + .WillOnce([&](Event::Dispatcher&, AdjustedByteRange, GetBodyCallback cb) { + cb(std::make_unique("abcde"), EndStream::More); + }); + result->http_source_->getBody(AdjustedByteRange(0, 9999), body_callback1.AsStdFunction()); + pumpDispatcher(); + // Asking for more body when there is no more returns a nullptr indicating it's + // time for trailers. + EXPECT_CALL(body_callback2, Call(IsNull(), EndStream::More)); + result->http_source_->getBody(AdjustedByteRange(5, 9999), body_callback2.AsStdFunction()); + pumpDispatcher(); + // Then finally the 'filter' asks for trailers, and gets them back immediately. + MockFunction trailer_callback; + EXPECT_CALL(trailer_callback, + Call(Pointee(IsSupersetOfHeaders(response_trailers)), EndStream::End)); + result->http_source_->getTrailers(trailer_callback.AsStdFunction()); + pumpDispatcher(); +} + +TEST_F(CacheSessionsTest, CacheInsertFailurePassesThroughLookupsAndWillLookupAgain) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(2); + ActiveLookupResultPtr result1, result2, result3; + auto response_headers = cacheableResponseHeadersByExpire(); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result2](ActiveLookupResultPtr r) { result2 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}))); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(1)); + std::shared_ptr progress; + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, IsNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_http_cache_); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, evict(_, KeyHasPath("/a"))); + progress->onInsertFailed(absl::InternalError("test error")); + pumpDispatcher(); + ASSERT_THAT(result1->http_source_, NotNull()); + ASSERT_THAT(result2->http_source_, NotNull()); + MockFunction header_callback1, header_callback2; + EXPECT_CALL(header_callback1, + Call(Pointee(Http::IsSupersetOfHeaders(*response_headers)), EndStream::End)); + EXPECT_CALL(header_callback2, + Call(Pointee(Http::IsSupersetOfHeaders(*response_headers)), EndStream::End)); + result1->http_source_->getHeaders(header_callback1.AsStdFunction()); + result2->http_source_->getHeaders(header_callback2.AsStdFunction()); + // Both requests should have a fresh upstream for pass-through. + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(3)); + consumeCallback(fake_upstream_get_headers_callbacks_[1])( + Http::createHeaderMap(*response_headers), EndStream::End); + consumeCallback(fake_upstream_get_headers_callbacks_[2])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + // A new request should provoke a new lookup because the previous insertion failed. + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result3](ActiveLookupResultPtr r) { result3 = std::move(r); }); + pumpDispatcher(); + // Should have sent a second lookup. + ASSERT_THAT(captured_lookup_callbacks_.size(), Eq(2)); + // Cache miss again. + consumeCallback(captured_lookup_callbacks_[1])(LookupResult{}); + pumpDispatcher(); + // Should be the original request, the two that pass-through, and the new request. + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(4)); + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, IsNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[3])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_http_cache_); + EXPECT_CALL(*mock_http_cache_, evict(_, KeyHasPath("/a"))); + progress->onInsertFailed(absl::InternalError("test error")); + pumpDispatcher(); + ASSERT_THAT(result3->http_source_, NotNull()); + // Should be yet another upstream request for the new pass-through. + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(5)); +} + +TEST_F(CacheSessionsTest, CacheInsertFailureResetsStreamingContexts) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(2); + ActiveLookupResultPtr result1, result2; + auto response_headers = cacheableResponseHeaders(); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result2](ActiveLookupResultPtr r) { result2 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}))); + ASSERT_THAT(fake_upstream_get_headers_callbacks_.size(), Eq(1)); + std::shared_ptr progress; + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, IsNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_http_cache_); + EXPECT_CALL(*mock_http_cache_, evict(_, KeyHasPath("/a"))); + progress->onHeadersInserted(std::make_unique(), + Http::createHeaderMap(*response_headers), + false); + pumpDispatcher(); + ASSERT_THAT(result1->http_source_, NotNull()); + ASSERT_THAT(result2->http_source_, NotNull()); + MockFunction body_callback; + MockFunction trailers_callback; + result1->http_source_->getBody(AdjustedByteRange(0, 5), body_callback.AsStdFunction()); + result2->http_source_->getTrailers(trailers_callback.AsStdFunction()); + EXPECT_CALL(body_callback, Call(IsNull(), EndStream::Reset)); + EXPECT_CALL(trailers_callback, Call(IsNull(), EndStream::Reset)); + progress->onInsertFailed(absl::InternalError("test error")); + pumpDispatcher(); +} + +TEST_F(CacheSessionsTest, MismatchedSizeAndContentLengthFromUpstreamLogsAnError) { + EXPECT_LOG_CONTAINS( + "error", "cache insert for test_host/a had content-length header 5 but actual size 3", { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)); + ActiveLookupResultPtr result1; + auto response_headers = cacheableResponseHeaders(5); + cache_sessions_->lookup(testLookupRequest("/a"), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + std::shared_ptr progress; + // Cacheable response. + EXPECT_CALL(*mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, + NotNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, std::shared_ptr receiver) { + progress = receiver; + }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::More); + pumpDispatcher(); + // The upstream was given to the cache; since it's a fake we can forget about + // that and just have the cache complete its write operations when we choose. + ASSERT_THAT(progress, NotNull()); + progress->onHeadersInserted( + std::make_unique(), + Http::createHeaderMap(*response_headers), false); + pumpDispatcher(); + // Actual body only 3 bytes despite content-length 5. + progress->onBodyInserted(AdjustedByteRange(0, 3), true); + pumpDispatcher(); + }); +} + +TEST_F(CacheSessionsTest, RangeRequestMissGetsFullResourceFromUpstreamAndServesRanges) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)).Times(2); + ActiveLookupResultPtr result1, result2; + auto response_headers = cacheableResponseHeaders(1024); + cache_sessions_->lookup(testLookupRangeRequest("/a", 0, 5), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + cache_sessions_->lookup(testLookupRangeRequest("/a", 5, 10), + [&result2](ActiveLookupResultPtr r) { result2 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + // Upstream request should have had the range header removed. + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(AllOf(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}), + HasNoHeader("range")))); + std::shared_ptr progress; + // Cacheable response. + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, IsNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_http_cache_); + MockFunction headers_callback1, headers_callback2; + progress->onHeadersInserted(std::unique_ptr(), + Http::createHeaderMap(*response_headers), + false); + pumpDispatcher(); + EXPECT_CALL(headers_callback1, + Call(Pointee(AllOf(HasHeader(":status", "206"), HasHeader("content-length", "6"), + HasHeader("content-range", "bytes 0-5/1024"))), + EndStream::More)); + EXPECT_CALL(headers_callback2, + Call(Pointee(AllOf(HasHeader(":status", "206"), HasHeader("content-length", "6"), + HasHeader("content-range", "bytes 5-10/1024"))), + EndStream::More)); + ASSERT_THAT(result1, NotNull()); + result1->http_source_->getHeaders(headers_callback1.AsStdFunction()); + result2->http_source_->getHeaders(headers_callback2.AsStdFunction()); + Mock::VerifyAndClearExpectations(&headers_callback1); + Mock::VerifyAndClearExpectations(&headers_callback2); + // No need to test the body behavior here because it's no different than + // how body ranges are requested by any other request - the difference + // in behavior there is controlled by the filter which is outside the scope + // of CacheSessions unit tests. +} + +TEST_F(CacheSessionsTest, RangeRequestWhenLengthIsUnknownReturnsNotSatisfiable) { + EXPECT_CALL(*mock_http_cache_, lookup(LookupHasPath("/a"), _)); + EXPECT_CALL(*mock_http_cache_, touch(KeyHasPath("/a"), _)); + ActiveLookupResultPtr result1; + auto response_headers = cacheableResponseHeaders(0); + cache_sessions_->lookup(testLookupRangeRequest("/a", 0, 5), + [&result1](ActiveLookupResultPtr r) { result1 = std::move(r); }); + pumpDispatcher(); + // Cache miss. + consumeCallback(captured_lookup_callbacks_[0])(LookupResult{}); + pumpDispatcher(); + // Upstream request should have been sent. + ASSERT_THAT(fake_upstreams_.size(), Eq(1)); + // Upstream request should have had the range header removed. + EXPECT_THAT(fake_upstream_sent_headers_[0], + Pointee(AllOf(IsSupersetOfHeaders(Http::TestRequestHeaderMapImpl{{":path", "/a"}}), + HasNoHeader("range")))); + std::shared_ptr progress; + // Cacheable response. + EXPECT_CALL( + *mock_http_cache_, + insert(_, KeyHasPath("/a"), Pointee(IsSupersetOfHeaders(*response_headers)), _, IsNull(), _)) + .WillOnce([&](Event::Dispatcher&, Key, Http::ResponseHeaderMapPtr, ResponseMetadata, + HttpSourcePtr, + std::shared_ptr receiver) { progress = receiver; }); + consumeCallback(fake_upstream_get_headers_callbacks_[0])( + Http::createHeaderMap(*response_headers), EndStream::End); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_http_cache_); + MockFunction headers_callback1; + progress->onHeadersInserted(std::unique_ptr(), + Http::createHeaderMap(*response_headers), + false); + pumpDispatcher(); + EXPECT_CALL(headers_callback1, Call(Pointee(HasHeader(":status", "416")), EndStream::End)); + ASSERT_THAT(result1, NotNull()); + result1->http_source_->getHeaders(headers_callback1.AsStdFunction()); + Mock::VerifyAndClearExpectations(&headers_callback1); +} + +// TODO: UpdateHeadersSkipSpecificHeaders +// TODO: Vary + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/cacheability_utils_test.cc b/test/extensions/filters/http/cache_v2/cacheability_utils_test.cc new file mode 100644 index 0000000000000..13cf8ca7840e1 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/cacheability_utils_test.cc @@ -0,0 +1,195 @@ +#include "envoy/http/header_map.h" + +#include "source/extensions/filters/http/cache_v2/cacheability_utils.h" + +#include "test/mocks/server/server_factory_context.h" +#include "test/test_common/status_utility.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +using StatusHelpers::HasStatus; +using testing::HasSubstr; + +class CanServeRequestFromCacheTest : public testing::Test { +protected: + Http::TestRequestHeaderMapImpl request_headers_ = { + {":path", "/"}, {":method", "GET"}, {":scheme", "http"}, {":authority", "test.com"}}; +}; + +class RequestConditionalHeadersTest : public testing::TestWithParam { +protected: + Http::TestRequestHeaderMapImpl request_headers_ = { + {":path", "/"}, {":method", "GET"}, {":scheme", "http"}, {":authority", "test.com"}}; + std::string conditionalHeader() const { return GetParam(); } +}; + +envoy::extensions::filters::http::cache_v2::v3::CacheV2Config getConfig() { + // Allows 'accept' to be varied in the tests. + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config config; + const auto& add_accept = config.mutable_allowed_vary_headers()->Add(); + add_accept->set_exact("accept"); + return config; +} + +class IsCacheableResponseTest : public testing::Test { +public: + IsCacheableResponseTest() + : vary_allow_list_(getConfig().allowed_vary_headers(), factory_context_) {} + +protected: + std::string cache_control_ = "max-age=3600"; + Http::TestResponseHeaderMapImpl response_headers_ = {{":status", "200"}, + {"date", "Sun, 06 Nov 1994 08:49:37 GMT"}, + {"cache-control", cache_control_}}; + + NiceMock factory_context_; + VaryAllowList vary_allow_list_; +}; + +TEST_F(CanServeRequestFromCacheTest, CacheableRequest) { + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); +} + +TEST_F(CanServeRequestFromCacheTest, PathHeader) { + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.removePath(); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("no path"))); +} + +TEST_F(CanServeRequestFromCacheTest, HostHeader) { + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.removeHost(); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("no host"))); +} + +TEST_F(CanServeRequestFromCacheTest, MethodHeader) { + const Http::HeaderValues& header_values = Http::Headers::get(); + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.setMethod(header_values.MethodValues.Post); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("POST"))); + request_headers_.setMethod(header_values.MethodValues.Put); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("PUT"))); + request_headers_.setMethod(header_values.MethodValues.Head); + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.removeMethod(); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("no method"))); +} + +TEST_F(CanServeRequestFromCacheTest, SchemeHeader) { + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.setScheme("ftp"); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("scheme"))); + request_headers_.removeScheme(); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("scheme"))); +} + +TEST_F(CanServeRequestFromCacheTest, AuthorizationHeader) { + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.setReferenceKey(Http::CustomHeaders::get().Authorization, + "basic YWxhZGRpbjpvcGVuc2VzYW1l"); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr("authorization"))); +} + +INSTANTIATE_TEST_SUITE_P(ConditionalHeaders, RequestConditionalHeadersTest, + testing::Values("if-none-match", "if-modified-since", "if-range"), + [](const auto& info) { + std::string test_name = info.param; + absl::c_replace_if( + test_name, [](char c) { return !std::isalnum(c); }, '_'); + return test_name; + }); + +TEST_P(RequestConditionalHeadersTest, ConditionalHeaders) { + EXPECT_OK(CacheabilityUtils::canServeRequestFromCache(request_headers_)); + request_headers_.setCopy(Http::LowerCaseString{conditionalHeader()}, "test-value"); + EXPECT_THAT(CacheabilityUtils::canServeRequestFromCache(request_headers_), + HasStatus(absl::StatusCode::kInvalidArgument, HasSubstr(conditionalHeader()))); +} + +TEST_F(IsCacheableResponseTest, CacheableResponse) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, UncacheableStatusCode) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + response_headers_.setStatus("700"); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + response_headers_.removeStatus(); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, ValidationData) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + // No cache control headers or expires header + response_headers_.remove(Http::CustomHeaders::get().CacheControl); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + // No max-age data or expires header + response_headers_.setReferenceKey(Http::CustomHeaders::get().CacheControl, + "public, no-transform"); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + // Max-age data available + response_headers_.setReferenceKey(Http::CustomHeaders::get().CacheControl, "s-maxage=1000"); + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + // No max-age data, but the response requires revalidation anyway + response_headers_.setReferenceKey(Http::CustomHeaders::get().CacheControl, "no-cache"); + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + // No cache control headers, but there is an expires header + response_headers_.remove(Http::CustomHeaders::get().CacheControl); + response_headers_.setReferenceKey(Http::CustomHeaders::get().Expires, + "Sun, 06 Nov 1994 09:49:37 GMT"); + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, ResponseNoStore) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + std::string cache_control_no_store = absl::StrCat(cache_control_, ", no-store"); + response_headers_.setReferenceKey(Http::CustomHeaders::get().CacheControl, + cache_control_no_store); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, ResponsePrivate) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + std::string cache_control_private = absl::StrCat(cache_control_, ", private"); + response_headers_.setReferenceKey(Http::CustomHeaders::get().CacheControl, cache_control_private); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, EmptyVary) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + response_headers_.setCopy(Http::CustomHeaders::get().Vary, ""); + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, AllowedVary) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + response_headers_.setCopy(Http::CustomHeaders::get().Vary, "accept"); + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +TEST_F(IsCacheableResponseTest, NotAllowedVary) { + EXPECT_TRUE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); + response_headers_.setCopy(Http::CustomHeaders::get().Vary, "*"); + EXPECT_FALSE(CacheabilityUtils::isCacheableResponse(response_headers_, vary_allow_list_)); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/config_test.cc b/test/extensions/filters/http/cache_v2/config_test.cc new file mode 100644 index 0000000000000..5508fd9ef334e --- /dev/null +++ b/test/extensions/filters/http/cache_v2/config_test.cc @@ -0,0 +1,90 @@ +#include "envoy/extensions/http/cache_v2/simple_http_cache/v3/config.pb.h" + +#include "source/extensions/filters/http/cache_v2/cache_filter.h" +#include "source/extensions/filters/http/cache_v2/config.h" + +#include "test/mocks/server/factory_context.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +class CacheFilterFactoryTest : public ::testing::Test { +protected: + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config config_; + NiceMock context_; + CacheFilterFactory factory_; + Http::MockFilterChainFactoryCallbacks filter_callback_; +}; + +TEST_F(CacheFilterFactoryTest, Basic) { + config_.mutable_typed_config()->PackFrom( + envoy::extensions::http::cache_v2::simple_http_cache::v3::SimpleHttpCacheV2Config()); + Http::FilterFactoryCb cb = + factory_.createFilterFactoryFromProto(config_, "stats", context_).value(); + Http::StreamFilterSharedPtr filter; + EXPECT_CALL(filter_callback_, addStreamFilter(_)).WillOnce(::testing::SaveArg<0>(&filter)); + cb(filter_callback_); + ASSERT(filter); + ASSERT(dynamic_cast(filter.get())); +} + +TEST_F(CacheFilterFactoryTest, Disabled) { + config_.mutable_disabled()->set_value(true); + Http::FilterFactoryCb cb = + factory_.createFilterFactoryFromProto(config_, "stats", context_).value(); + Http::StreamFilterSharedPtr filter; + EXPECT_CALL(filter_callback_, addStreamFilter(_)).WillOnce(::testing::SaveArg<0>(&filter)); + cb(filter_callback_); + ASSERT(filter); + ASSERT(dynamic_cast(filter.get())); +} + +TEST_F(CacheFilterFactoryTest, NoTypedConfig) { + EXPECT_THROW( + factory_.createFilterFactoryFromProto(config_, "stats", context_).status().IgnoreError(), + EnvoyException); +} + +TEST_F(CacheFilterFactoryTest, UnregisteredTypedConfig) { + config_.mutable_typed_config()->PackFrom( + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config()); + EXPECT_THROW( + factory_.createFilterFactoryFromProto(config_, "stats", context_).status().IgnoreError(), + EnvoyException); +} + +class FailToCreateCacheFactory : public HttpCacheFactory { +public: + std::string name() const override { + return std::string("envoy.extensions.http.cache_v2.fake_fail"); + } + // Arbitrarily use "Key" as the proto type of the config because it's convenient, + // and we have to register it as *some* type of proto message. + ProtobufTypes::MessagePtr createEmptyConfigProto() override { return std::make_unique(); } + absl::StatusOr> + getCache(const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config&, + Server::Configuration::FactoryContext&) override { + return absl::InvalidArgumentError("intentional fail"); + } +}; + +static Registry::RegisterFactory register_; + +TEST_F(CacheFilterFactoryTest, FactoryFailsToCreateCache) { + config_.mutable_typed_config()->PackFrom(Key()); + EXPECT_THROW( + factory_.createFilterFactoryFromProto(config_, "stats", context_).status().IgnoreError(), + EnvoyException); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.cc b/test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.cc new file mode 100644 index 0000000000000..92c09b8596f88 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.cc @@ -0,0 +1,496 @@ +#include "test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.h" + +#include +#include +#include + +#include "source/common/common/assert.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +#include "test/extensions/filters/http/cache_v2/mocks.h" +#include "test/mocks/http/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "absl/cleanup/cleanup.h" +#include "absl/status/status.h" +#include "gtest/gtest.h" + +using ::envoy::extensions::filters::http::cache_v2::v3::CacheV2Config; +using ::testing::_; +using ::testing::AnyNumber; +using ::testing::Eq; +using ::testing::Ge; +using ::testing::Mock; +using ::testing::MockFunction; +using ::testing::NotNull; +using ::testing::Optional; +using ::testing::Pair; +using ::testing::Pointee; +using ::testing::Property; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +inline constexpr auto RangeIs = [](const auto& m1, const auto& m2) { + return AllOf(Property("begin", &AdjustedByteRange::begin, m1), + Property("end", &AdjustedByteRange::end, m2)); +}; + +void HttpCacheTestDelegate::pumpDispatcher() { + // There may be multiple steps in a cache operation going back and forth with work + // on a cache's thread and work on the filter's thread. So drain both things up to + // 10 times each. This number is arbitrary and could be increased if necessary for + // a cache implementation. + for (int i = 0; i < 10; i++) { + beforePumpingDispatcher(); + dispatcher().run(Event::Dispatcher::RunType::Block); + } +} + +HttpCacheImplementationTest::HttpCacheImplementationTest() : delegate_(GetParam()()) { + request_headers_.setMethod("GET"); + request_headers_.setHost("example.com"); + request_headers_.setScheme("https"); + request_headers_.setCopy(Http::CustomHeaders::get().CacheControl, "max-age=3600"); + delegate_->setUp(); +} + +HttpCacheImplementationTest::~HttpCacheImplementationTest() { + Assert::resetEnvoyBugCountersForTest(); + + delegate_->tearDown(); +} + +void HttpCacheImplementationTest::updateHeaders( + absl::string_view request_path, const Http::TestResponseHeaderMapImpl& response_headers, + const ResponseMetadata& metadata) { + Key key = simpleKey(request_path); + cache().updateHeaders(dispatcher(), key, response_headers, metadata); + pumpDispatcher(); +} + +LookupResult HttpCacheImplementationTest::lookup(absl::string_view request_path) { + LookupRequest request = makeLookupRequest(request_path); + LookupResult result; + bool seen_result = false; + cache().lookup(std::move(request), [&result, &seen_result](absl::StatusOr&& r) { + result = std::move(r.value()); + seen_result = true; + }); + pumpDispatcher(); + EXPECT_TRUE(seen_result); + return result; +} + +CacheReaderPtr HttpCacheImplementationTest::insert( + Key key, const Http::TestResponseHeaderMapImpl& headers, const absl::string_view body, + const absl::optional trailers) { + // For responses with body, we must wait for insertBody's callback before + // calling insertTrailers or completing. Note, in a multipart body test this + // would need to check for the callback having been called for *every* body part, + // but since the test only uses single-part bodies, inserting trailers or + // completing in direct response to the callback works. + uint64_t body_insert_pos = 0; + bool last_body_end_stream = false; + std::unique_ptr source; + bool end_stream_after_headers = body.empty() && !trailers; + if (!end_stream_after_headers) { + source = std::make_unique( + dispatcher(), nullptr, body, + trailers ? Http::createHeaderMap(*trailers) : nullptr); + } + const ResponseMetadata metadata{time_system_.systemTime()}; + auto mock_progress_receiver = std::make_shared(); + CacheReaderPtr cache_reader; + EXPECT_CALL(*mock_progress_receiver, + onHeadersInserted(_, HeaderMapEqualIgnoreOrder(&headers), end_stream_after_headers)) + .WillOnce([&cache_reader](CacheReaderPtr cr, Http::ResponseHeaderMapPtr, bool) { + cache_reader = std::move(cr); + }); + cache().insert(dispatcher(), key, Http::createHeaderMap(headers), + metadata, std::move(source), mock_progress_receiver); + if (!end_stream_after_headers) { + EXPECT_CALL(*mock_progress_receiver, onBodyInserted) + .WillRepeatedly([&](AdjustedByteRange range, bool end_stream) { + EXPECT_THAT(range.begin(), Eq(body_insert_pos)); + body_insert_pos = range.end(); + EXPECT_FALSE(last_body_end_stream); + last_body_end_stream = end_stream; + }); + } + if (trailers) { + EXPECT_CALL(*mock_progress_receiver, onTrailersInserted(HeaderMapEqualIgnoreOrder(trailers))); + } + pumpDispatcher(); + if (!end_stream_after_headers) { + EXPECT_THAT(body_insert_pos, Eq(body.size())); + EXPECT_THAT(last_body_end_stream, Eq(trailers ? false : true)); + } + return cache_reader; +} + +CacheReaderPtr HttpCacheImplementationTest::insert( + absl::string_view request_path, const Http::TestResponseHeaderMapImpl& headers, + const absl::string_view body, const absl::optional trailers) { + return insert(simpleKey(request_path), headers, body, trailers); +} + +std::pair +HttpCacheImplementationTest::getBody(CacheReader& reader, uint64_t start, uint64_t end) { + AdjustedByteRange range(start, end); + std::pair returned_pair; + bool seen_result = false; + reader.getBody(dispatcher(), range, + [&returned_pair, &seen_result](Buffer::InstancePtr data, EndStream end_stream) { + returned_pair = std::make_pair(data->toString(), end_stream); + seen_result = true; + }); + pumpDispatcher(); + EXPECT_TRUE(seen_result); + return returned_pair; +} + +Key HttpCacheImplementationTest::simpleKey(absl::string_view request_path) const { + Key key; + key.set_path(request_path); + return key; +} + +LookupRequest HttpCacheImplementationTest::makeLookupRequest(absl::string_view request_path) const { + return {simpleKey(request_path), dispatcher()}; +} + +// Simple flow of putting in an item, getting it, deleting it. +TEST_P(HttpCacheImplementationTest, PutGet) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + + const std::string body1("Value"); + insert(request_path1, response_headers, body1); + lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Optional(5)); + EXPECT_THAT(lookup_result.response_headers_, HeaderMapEqualIgnoreOrder(&response_headers)); + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 0, 5), Pair("Value", EndStream::More)); + + const std::string& request_path_2("/another-name"); + LookupResult another_name_lookup_result = lookup(request_path_2); + EXPECT_THAT(another_name_lookup_result.body_length_, Eq(absl::nullopt)); + + const std::string new_body1("NewValue"); + insert(request_path_2, response_headers, new_body1); + lookup_result = lookup(request_path_2); + EXPECT_THAT(lookup_result.body_length_, Optional(8)); + EXPECT_THAT(lookup_result.response_headers_, HeaderMapEqualIgnoreOrder(&response_headers)); + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 0, 8), Pair("NewValue", EndStream::More)); + // Also check that reading chunks of body from arbitrary positions works. + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 0, 4), Pair("NewV", EndStream::More)); + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 3, 8), Pair("Value", EndStream::More)); +} + +TEST_P(HttpCacheImplementationTest, UpdateHeadersAndMetadata) { + const std::string request_path_1("/name"); + + { + Http::TestResponseHeaderMapImpl response_headers{ + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}, + {":status", "200"}, + {"etag", "\"foo\""}, + {"content-length", "4"}}; + + insert(request_path_1, response_headers, "body"); + LookupResult lookup_result = lookup(request_path_1); + EXPECT_THAT(lookup_result.body_length_, Optional(4)); + } + + // Update the date field in the headers + time_system_.advanceTimeWait(Seconds(3601)); + + { + Http::TestResponseHeaderMapImpl response_headers = + Http::TestResponseHeaderMapImpl{{"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}, + {":status", "200"}, + {"etag", "\"foo\""}, + {"content-length", "4"}}; + updateHeaders(request_path_1, response_headers, {time_system_.systemTime()}); + LookupResult lookup_result = lookup(request_path_1); + + EXPECT_THAT(lookup_result.response_headers_.get(), + HeaderMapEqualIgnoreOrder(&response_headers)); + } +} + +TEST_P(HttpCacheImplementationTest, UpdateHeadersForMissingKeyFails) { + const std::string request_path_1("/name"); + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}, + {"etag", "\"foo\""}, + }; + time_system_.advanceTimeWait(Seconds(3601)); + updateHeaders(request_path_1, response_headers, {time_system_.systemTime()}); + LookupResult lookup_result = lookup(request_path_1); + EXPECT_FALSE(lookup_result.body_length_.has_value()); +} + +TEST_P(HttpCacheImplementationTest, PutGetWithTrailers) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + + Http::TestResponseTrailerMapImpl response_trailers{{"x-trailer1", "hello"}, + {"x-trailer2", "world"}}; + + const std::string body1("Value"); + insert(request_path1, response_headers, body1, response_trailers); + lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Optional(5)); + EXPECT_THAT(lookup_result.response_headers_, HeaderMapEqualIgnoreOrder(&response_headers)); + EXPECT_THAT(lookup_result.response_trailers_, HeaderMapEqualIgnoreOrder(&response_trailers)); + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 0, 5), Pair("Value", EndStream::More)); + + const std::string& request_path_2("/another-name"); + LookupResult another_name_lookup_result = lookup(request_path_2); + EXPECT_THAT(another_name_lookup_result.body_length_, Eq(absl::nullopt)); + + const std::string new_body1("NewValue"); + insert(request_path_2, response_headers, new_body1, response_trailers); + lookup_result = lookup(request_path_2); + EXPECT_THAT(lookup_result.body_length_, Optional(8)); + EXPECT_THAT(lookup_result.response_headers_, HeaderMapEqualIgnoreOrder(&response_headers)); + EXPECT_THAT(lookup_result.response_trailers_, HeaderMapEqualIgnoreOrder(&response_trailers)); + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 0, 8), Pair("NewValue", EndStream::More)); + // Also check that reading chunks of body from arbitrary positions works. + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 0, 4), Pair("NewV", EndStream::More)); + EXPECT_THAT(getBody(*lookup_result.cache_reader_, 3, 8), Pair("Value", EndStream::More)); +} + +TEST_P(HttpCacheImplementationTest, InsertReadingNullBufferBodyWithEndStream) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + const std::string body("Hello World"); + auto source = std::make_unique(); + GetBodyCallback get_body_1, get_body_2; + EXPECT_CALL(*source, getBody(RangeIs(0, Ge(11)), _)) + .WillOnce([&](AdjustedByteRange, GetBodyCallback cb) { get_body_1 = std::move(cb); }); + EXPECT_CALL(*source, getBody(RangeIs(11, Ge(11)), _)) + .WillOnce([&](AdjustedByteRange, GetBodyCallback cb) { get_body_2 = std::move(cb); }); + const ResponseMetadata metadata{time_system_.systemTime()}; + auto mock_progress_receiver = std::make_shared(); + CacheReaderPtr cache_reader; + EXPECT_CALL(*mock_progress_receiver, + onHeadersInserted(_, HeaderMapEqualIgnoreOrder(&response_headers), false)) + .WillOnce([&cache_reader](CacheReaderPtr cr, Http::ResponseHeaderMapPtr, bool) { + cache_reader = std::move(cr); + }); + cache().insert(dispatcher(), simpleKey(request_path1), + Http::createHeaderMap(response_headers), metadata, + std::move(source), mock_progress_receiver); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + ASSERT_THAT(cache_reader, NotNull()); + ASSERT_THAT(get_body_1, NotNull()); + EXPECT_CALL(*mock_progress_receiver, onBodyInserted(RangeIs(0, 11), false)); + get_body_1(std::make_unique("Hello World"), EndStream::More); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + ASSERT_THAT(get_body_2, NotNull()); + EXPECT_CALL(*mock_progress_receiver, onBodyInserted(RangeIs(0, 11), true)); + get_body_2(nullptr, EndStream::End); + pumpDispatcher(); +} + +TEST_P(HttpCacheImplementationTest, HeadersOnlyInsert) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + + const ResponseMetadata metadata{time_system_.systemTime()}; + auto mock_progress_receiver = std::make_shared(); + EXPECT_CALL(*mock_progress_receiver, + onHeadersInserted(_, HeaderMapEqualIgnoreOrder(&response_headers), true)); + // source=nullptr indicates that the response was headers-only. + cache().insert(dispatcher(), simpleKey(request_path1), + Http::createHeaderMap(response_headers), metadata, + nullptr, mock_progress_receiver); + pumpDispatcher(); +} + +TEST_P(HttpCacheImplementationTest, ReadingFromBodyDuringInsert) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + + const std::string body("Hello World"); + auto source = std::make_unique(); + GetBodyCallback get_body_1, get_body_2; + EXPECT_CALL(*source, getBody(RangeIs(0, Ge(6)), _)) + .WillOnce([&](AdjustedByteRange, GetBodyCallback cb) { get_body_1 = std::move(cb); }); + EXPECT_CALL(*source, getBody(RangeIs(6, Ge(11)), _)) + .WillOnce([&](AdjustedByteRange, GetBodyCallback cb) { get_body_2 = std::move(cb); }); + const ResponseMetadata metadata{time_system_.systemTime()}; + auto mock_progress_receiver = std::make_shared(); + CacheReaderPtr cache_reader; + EXPECT_CALL(*mock_progress_receiver, + onHeadersInserted(_, HeaderMapEqualIgnoreOrder(&response_headers), false)) + .WillOnce([&cache_reader](CacheReaderPtr cr, Http::ResponseHeaderMapPtr, bool) { + cache_reader = std::move(cr); + }); + cache().insert(dispatcher(), simpleKey(request_path1), + Http::createHeaderMap(response_headers), metadata, + std::move(source), mock_progress_receiver); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + ASSERT_THAT(cache_reader, NotNull()); + ASSERT_THAT(get_body_1, NotNull()); + EXPECT_CALL(*mock_progress_receiver, onBodyInserted(RangeIs(0, 6), false)); + get_body_1(std::make_unique("Hello "), EndStream::More); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + MockFunction mock_body_callback; + EXPECT_CALL(mock_body_callback, Call(Pointee(BufferStringEqual("Hello ")), EndStream::More)); + cache_reader->getBody(dispatcher(), AdjustedByteRange(0, 6), mock_body_callback.AsStdFunction()); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(&mock_body_callback); + ASSERT_THAT(get_body_2, NotNull()); + EXPECT_CALL(*mock_progress_receiver, onBodyInserted(RangeIs(6, 11), true)); + get_body_2(std::make_unique("World"), EndStream::End); + pumpDispatcher(); + EXPECT_CALL(mock_body_callback, Call(Pointee(BufferStringEqual("Hello World")), EndStream::More)); + cache_reader->getBody(dispatcher(), AdjustedByteRange(0, 11), mock_body_callback.AsStdFunction()); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(&mock_body_callback); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); +} + +TEST_P(HttpCacheImplementationTest, UpstreamResetWhileExpectingBodyShouldBeInsertFailed) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + + const std::string body("Hello World"); + auto source = std::make_unique(); + GetBodyCallback get_body_1; + EXPECT_CALL(*source, getBody(RangeIs(0, Ge(11)), _)) + .WillOnce([&](AdjustedByteRange, GetBodyCallback cb) { get_body_1 = std::move(cb); }); + const ResponseMetadata metadata{time_system_.systemTime()}; + auto mock_progress_receiver = std::make_shared(); + CacheReaderPtr cache_reader; + EXPECT_CALL(*mock_progress_receiver, + onHeadersInserted(_, HeaderMapEqualIgnoreOrder(&response_headers), false)) + .WillOnce([&cache_reader](CacheReaderPtr cr, Http::ResponseHeaderMapPtr, bool) { + cache_reader = std::move(cr); + }); + cache().insert(dispatcher(), simpleKey(request_path1), + Http::createHeaderMap(response_headers), metadata, + std::move(source), mock_progress_receiver); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + ASSERT_THAT(cache_reader, NotNull()); + ASSERT_THAT(get_body_1, NotNull()); + EXPECT_CALL(*mock_progress_receiver, onInsertFailed); + get_body_1(nullptr, EndStream::Reset); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); +} + +TEST_P(HttpCacheImplementationTest, TouchOnExistingEntryHasNoExternallyVisibleEffect) { + auto key = simpleKey("/name"); + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + insert(key, response_headers, ""); + cache().touch(key, SystemTime()); +} + +TEST_P(HttpCacheImplementationTest, TouchOnAbsentEntryHasNoExternallyVisibleEffect) { + auto key = simpleKey("/name"); + cache().touch(key, SystemTime()); +} + +TEST_P(HttpCacheImplementationTest, UpstreamResetWhileExpectingTrailersShouldBeInsertFailed) { + const std::string request_path1("/name"); + LookupResult lookup_result = lookup(request_path1); + EXPECT_THAT(lookup_result.body_length_, Eq(absl::nullopt)); + + Http::TestResponseHeaderMapImpl response_headers{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}}; + + const std::string body("Hello World"); + auto source = std::make_unique(); + GetBodyCallback get_body_1; + GetTrailersCallback get_trailers; + EXPECT_CALL(*source, getBody(RangeIs(0, Ge(6)), _)) + .WillOnce([&](AdjustedByteRange, GetBodyCallback cb) { get_body_1 = std::move(cb); }); + EXPECT_CALL(*source, getTrailers(_)).WillOnce([&](GetTrailersCallback cb) { + get_trailers = std::move(cb); + }); + const ResponseMetadata metadata{time_system_.systemTime()}; + auto mock_progress_receiver = std::make_shared(); + CacheReaderPtr cache_reader; + EXPECT_CALL(*mock_progress_receiver, + onHeadersInserted(_, HeaderMapEqualIgnoreOrder(&response_headers), false)) + .WillOnce([&cache_reader](CacheReaderPtr cr, Http::ResponseHeaderMapPtr, bool) { + cache_reader = std::move(cr); + }); + cache().insert(dispatcher(), simpleKey(request_path1), + Http::createHeaderMap(response_headers), metadata, + std::move(source), mock_progress_receiver); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + ASSERT_THAT(cache_reader, NotNull()); + ASSERT_THAT(get_body_1, NotNull()); + // Null body + EndStream::More signifies trailers. + get_body_1(nullptr, EndStream::More); + pumpDispatcher(); + Mock::VerifyAndClearExpectations(mock_progress_receiver.get()); + ASSERT_THAT(get_trailers, NotNull()); + EXPECT_CALL(*mock_progress_receiver, onInsertFailed); + get_trailers(nullptr, EndStream::Reset); + pumpDispatcher(); +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.h b/test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.h new file mode 100644 index 0000000000000..8ddfbd6a82aea --- /dev/null +++ b/test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include + +#include "source/common/common/utility.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +#include "test/mocks/event/mocks.h" +#include "test/mocks/http/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "absl/strings/string_view.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +// Delegate class for holding the cache. Needed because TEST_P can't be used +// with an abstract test fixture, even if the tests are only instantiated with +// concrete subclasses. +class HttpCacheTestDelegate { +public: + virtual ~HttpCacheTestDelegate() = default; + + virtual void setUp() {} + virtual void tearDown() {} + + virtual HttpCache& cache() PURE; + + // May be overridden to, for example, also drain other threads into the dispatcher + // before draining the dispatcher. + virtual void beforePumpingDispatcher() {}; + void pumpDispatcher(); + + Event::Dispatcher& dispatcher() { return *dispatcher_; } + +private: + Api::ApiPtr api_ = Api::createApiForTest(); + Event::DispatcherPtr dispatcher_ = api_->allocateDispatcher("test_thread"); +}; + +class HttpCacheImplementationTest + : public Event::TestUsingSimulatedTime, + public testing::TestWithParam()>> { +public: + static constexpr absl::Duration kLastValidUpdateMinInterval = absl::Seconds(10); + +protected: + HttpCacheImplementationTest(); + ~HttpCacheImplementationTest() override; + + HttpCache& cache() const { return delegate_->cache(); } + void pumpIntoDispatcher() { delegate_->beforePumpingDispatcher(); } + void pumpDispatcher() { delegate_->pumpDispatcher(); } + LookupResult lookup(absl::string_view request_path); + + virtual CacheReaderPtr + insert(Key key, const Http::TestResponseHeaderMapImpl& headers, const absl::string_view body, + const absl::optional trailers = absl::nullopt); + + CacheReaderPtr + insert(absl::string_view request_path, const Http::TestResponseHeaderMapImpl& headers, + const absl::string_view body, + const absl::optional trailers = absl::nullopt); + + std::pair getBody(CacheReader& reader, uint64_t start, uint64_t end); + + void evict(absl::string_view request_path); + + void updateHeaders(absl::string_view request_path, + const Http::TestResponseHeaderMapImpl& response_headers, + const ResponseMetadata& metadata); + + Key simpleKey(absl::string_view request_path) const; + LookupRequest makeLookupRequest(absl::string_view request_path) const; + + std::unique_ptr delegate_; + Http::TestRequestHeaderMapImpl request_headers_; + Event::SimulatedTimeSystem time_system_; + Event::Dispatcher& dispatcher() const { return delegate_->dispatcher(); } + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/http_cache_test.cc b/test/extensions/filters/http/cache_v2/http_cache_test.cc new file mode 100644 index 0000000000000..6e879ba916bb8 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/http_cache_test.cc @@ -0,0 +1,22 @@ +#include "source/extensions/filters/http/cache_v2/http_cache.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +namespace { + +TEST(HttpCacheTest, StableHashKey) { + Key key; + key.set_host("example.com"); + ASSERT_EQ(stableHashKey(key), 2966927868601563246); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/mocks.cc b/test/extensions/filters/http/cache_v2/mocks.cc new file mode 100644 index 0000000000000..2d40f0567c6c0 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/mocks.cc @@ -0,0 +1,73 @@ +#include "test/extensions/filters/http/cache_v2/mocks.h" + +#include "source/common/buffer/buffer_impl.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +void PrintTo(const EndStream& end_stream, std::ostream* os) { + static const absl::flat_hash_map vmap{ + {EndStream::End, "End"}, + {EndStream::More, "More"}, + {EndStream::Reset, "Reset"}, + }; + *os << "EndStream::" << vmap.at(end_stream); +} + +void PrintTo(const Key& key, std::ostream* os) { *os << key.DebugString(); } + +using testing::NotNull; + +FakeStreamHttpSource::FakeStreamHttpSource(Event::Dispatcher& dispatcher, + Http::ResponseHeaderMapPtr headers, + absl::string_view body, + Http::ResponseTrailerMapPtr trailers) + : dispatcher_(dispatcher), headers_(std::move(headers)), body_(body), + trailers_(std::move(trailers)) {} + +void FakeStreamHttpSource::getHeaders(GetHeadersCallback&& cb) { + ASSERT_THAT(headers_, NotNull()); + EndStream end_stream = (!body_.empty() || trailers_) ? EndStream::More : EndStream::End; + dispatcher_.post([headers = std::move(headers_), cb = std::move(cb), end_stream]() mutable { + cb(std::move(headers), end_stream); + }); +} + +void FakeStreamHttpSource::getBody(AdjustedByteRange range, GetBodyCallback&& cb) { + if (body_.empty()) { + cb(nullptr, trailers_ ? EndStream::More : EndStream::End); + } else { + if (range.length() > max_fragment_size_) { + range = AdjustedByteRange(range.begin(), range.begin() + max_fragment_size_); + } + ASSERT_THAT(range.begin(), testing::Ge(body_pos_)) + << "getBody called out of order, pos=" << body_pos_ << ", range=[" << range.begin() << ", " + << range.end() << ")"; + if (range.begin() == body_.size()) { + cb(nullptr, trailers_ ? EndStream::More : EndStream::End); + } else { + range = AdjustedByteRange(range.begin(), std::min(range.end(), body_.size())); + EndStream end_stream = + (trailers_ || range.end() < body_.size()) ? EndStream::More : EndStream::End; + Buffer::InstancePtr fragment = std::make_unique( + absl::string_view{body_}.substr(range.begin(), range.length())); + dispatcher_.post([cb = std::move(cb), fragment = std::move(fragment), end_stream]() mutable { + cb(std::move(fragment), end_stream); + }); + body_pos_ = range.end(); + } + } +} + +void FakeStreamHttpSource::getTrailers(GetTrailersCallback&& cb) { + ASSERT_THAT(trailers_, NotNull()) + << "should have stopped on an earlier EndStream::End not called getTrailers"; + cb(std::move(trailers_), EndStream::End); +} + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/mocks.h b/test/extensions/filters/http/cache_v2/mocks.h new file mode 100644 index 0000000000000..f271449c08e77 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/mocks.h @@ -0,0 +1,151 @@ +#pragma once + +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/filters/http/cache_v2/http_cache.h" +#include "source/extensions/filters/http/cache_v2/http_source.h" +#include "source/extensions/filters/http/cache_v2/stats.h" + +#include "test/test_common/printers.h" + +#include "gmock/gmock.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { + +void PrintTo(const EndStream& end_stream, std::ostream* os); +void PrintTo(const Key& key, std::ostream* os); + +class MockCacheFilterStats : public CacheFilterStats { +public: + MOCK_METHOD(void, incForStatus, (CacheEntryStatus s)); + MOCK_METHOD(void, incCacheSessionsEntries, ()); + MOCK_METHOD(void, decCacheSessionsEntries, ()); + MOCK_METHOD(void, incCacheSessionsSubscribers, ()); + MOCK_METHOD(void, subCacheSessionsSubscribers, (uint64_t count)); + MOCK_METHOD(void, addUpstreamBufferedBytes, (uint64_t bytes)); + MOCK_METHOD(void, subUpstreamBufferedBytes, (uint64_t bytes)); +}; + +class MockCacheSessions : public CacheSessions { +public: + MockCacheSessions() { + EXPECT_CALL(*this, stats) + .Times(testing::AnyNumber()) + .WillRepeatedly(testing::ReturnRef(mock_stats_)); + } + MOCK_METHOD(void, lookup, (ActiveLookupRequestPtr request, ActiveLookupResultCallback&& cb)); + MOCK_METHOD(CacheInfo, cacheInfo, (), (const)); + MOCK_METHOD(HttpCache&, cache, (), (const)); + MOCK_METHOD(CacheFilterStats&, stats, (), (const)); + testing::NiceMock mock_stats_; +}; + +class MockHttpCache : public HttpCache { +public: + MockHttpCache() { + EXPECT_CALL(*this, cacheInfo) + .Times(testing::AnyNumber()) + .WillRepeatedly(testing::Return(CacheInfo{"mock_cache"})); + } + MOCK_METHOD(void, lookup, (LookupRequest && request, LookupCallback&& callback)); + MOCK_METHOD(void, evict, (Event::Dispatcher & dispatcher, const Key& key)); + MOCK_METHOD(void, touch, (const Key& key, SystemTime timestamp)); + MOCK_METHOD(void, updateHeaders, + (Event::Dispatcher & dispatcher, const Key& key, + const Http::ResponseHeaderMap& updated_headers, + const ResponseMetadata& updated_metadata)); + MOCK_METHOD(CacheInfo, cacheInfo, (), (const)); + MOCK_METHOD(void, insert, + (Event::Dispatcher & dispatcher, Key key, Http::ResponseHeaderMapPtr headers, + ResponseMetadata metadata, HttpSourcePtr source, + std::shared_ptr progress)); +}; + +class MockCacheReader : public CacheReader { +public: + MOCK_METHOD(void, getBody, + (Event::Dispatcher & dispatcher, AdjustedByteRange range, GetBodyCallback&& cb)); +}; + +class MockHttpSource : public HttpSource { +public: + MOCK_METHOD(void, getHeaders, (GetHeadersCallback && cb)); + MOCK_METHOD(void, getBody, (AdjustedByteRange range, GetBodyCallback&& cb)); + MOCK_METHOD(void, getTrailers, (GetTrailersCallback && cb)); +}; + +class MockCacheFilterStatsProvider : public CacheFilterStatsProvider { +public: + MockCacheFilterStatsProvider() { + ON_CALL(*this, stats).WillByDefault(testing::ReturnRef(mock_stats_)); + } + MOCK_METHOD(CacheFilterStats&, stats, (), (const)); + testing::NiceMock mock_stats_; +}; + +class FakeStreamHttpSource : public HttpSource { +public: + // Any field can be nullptr; if headers is nullptr it's assumed headers have + // already been consumed. Body and trailers being nullptr imply the resource had + // no body or trailers respectively. + FakeStreamHttpSource(Event::Dispatcher& dispatcher, Http::ResponseHeaderMapPtr headers, + absl::string_view body, Http::ResponseTrailerMapPtr trailers); + void getHeaders(GetHeadersCallback&& cb) override; + // This will use the dispatcher, to better resemble the behavior of an actual + // async http stream. + void getBody(AdjustedByteRange range, GetBodyCallback&& cb) override; + void getTrailers(GetTrailersCallback&& cb) override; + void setMaxFragmentSize(uint64_t v) { max_fragment_size_ = v; } + +private: + Event::Dispatcher& dispatcher_; + Http::ResponseHeaderMapPtr headers_; + std::string body_; + Http::ResponseTrailerMapPtr trailers_; + uint64_t body_pos_{0}; + uint64_t max_fragment_size_ = std::numeric_limits::max(); +}; + +class MockCacheProgressReceiver : public CacheProgressReceiver { +public: + MOCK_METHOD(void, onHeadersInserted, + (CacheReaderPtr cache_reader, Http::ResponseHeaderMapPtr headers, bool end_stream)); + MOCK_METHOD(void, onBodyInserted, (AdjustedByteRange range, bool end_stream)); + MOCK_METHOD(void, onTrailersInserted, (Http::ResponseTrailerMapPtr trailers)); + MOCK_METHOD(void, onInsertFailed, (absl::Status)); +}; + +class MockHttpCacheFactory : public HttpCacheFactory { +public: + MOCK_METHOD(absl::StatusOr>, getCache, + (const envoy::extensions::filters::http::cache_v2::v3::CacheV2Config& config, + Server::Configuration::FactoryContext& context)); +}; + +class MockUpstreamRequest : public UpstreamRequest { +public: + // HttpSource + MOCK_METHOD(void, getHeaders, (GetHeadersCallback && cb)); + MOCK_METHOD(void, getBody, (AdjustedByteRange range, GetBodyCallback&& cb)); + MOCK_METHOD(void, getTrailers, (GetTrailersCallback && cb)); + // UpstreamRequest only + MOCK_METHOD(void, sendHeaders, (Http::RequestHeaderMapPtr h)); +}; + +class MockUpstreamRequestFactory : public UpstreamRequestFactory { +public: + MOCK_METHOD(UpstreamRequestPtr, create, + (const std::shared_ptr stats_provider)); +}; + +class MockCacheableResponseChecker : public CacheableResponseChecker { +public: + MOCK_METHOD(bool, isCacheableResponse, (const Http::ResponseHeaderMap& h), (const)); +}; + +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/range_utils_test.cc b/test/extensions/filters/http/cache_v2/range_utils_test.cc new file mode 100644 index 0000000000000..a5bbd70094cb3 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/range_utils_test.cc @@ -0,0 +1,343 @@ +#include +#include +#include +#include + +#include "source/extensions/filters/http/cache_v2/range_utils.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +TEST(RawByteRangeTest, IsSuffix) { + auto r = RawByteRange(UINT64_MAX, 4); + ASSERT_TRUE(r.isSuffix()); +} + +TEST(RawByteRangeTest, IsNotSuffix) { + auto r = RawByteRange(3, 4); + ASSERT_FALSE(r.isSuffix()); +} + +TEST(RawByteRangeTest, FirstBytePos) { + auto r = RawByteRange(3, 4); + ASSERT_EQ(3, r.firstBytePos()); +} + +TEST(RawByteRangeTest, LastBytePos) { + auto r = RawByteRange(3, 4); + ASSERT_EQ(4, r.lastBytePos()); +} + +TEST(RawByteRangeTest, SuffixLength) { + auto r = RawByteRange(UINT64_MAX, 4); + ASSERT_EQ(4, r.suffixLength()); +} + +TEST(AdjustedByteRangeTest, Length) { + auto a = AdjustedByteRange(3, 6); + ASSERT_EQ(3, a.length()); +} + +TEST(AdjustedByteRangeTest, TrimFront) { + auto a = AdjustedByteRange(3, 6); + a.trimFront(2); + ASSERT_EQ(5, a.begin()); +} + +TEST(AdjustedByteRangeTest, MaxLength) { + auto a = AdjustedByteRange(0, UINT64_MAX); + ASSERT_EQ(UINT64_MAX, a.length()); +} + +TEST(AdjustedByteRangeTest, MaxTrim) { + auto a = AdjustedByteRange(0, UINT64_MAX); + a.trimFront(UINT64_MAX); + ASSERT_EQ(0, a.length()); +} + +struct AdjustByteRangeParams { + std::vector request; + std::vector result; + uint64_t content_length; +}; + +AdjustByteRangeParams satisfiable_ranges[] = + // request, result, content_length + { + // Various ways to request the full body. Full responses are signaled by + // empty result vectors. + {{{0, 3}}, {}, 4}, // byte-range-spec, exact + {{{UINT64_MAX, 4}}, {}, 4}, // suffix-byte-range-spec, exact + {{{0, 99}}, {}, 4}, // byte-range-spec, overlong + {{{0, UINT64_MAX}}, {}, 4}, // byte-range-spec, overlong + {{{UINT64_MAX, 5}}, {}, 4}, // suffix-byte-range-spec, overlong + {{{UINT64_MAX, UINT64_MAX - 1}}, {}, 4}, // suffix-byte-range-spec, overlong + {{{UINT64_MAX, UINT64_MAX}}, {}, 4}, // suffix-byte-range-spec, overlong + + // Single bytes + {{{0, 0}}, {{0, 1}}, 4}, + {{{1, 1}}, {{1, 2}}, 4}, + {{{3, 3}}, {{3, 4}}, 4}, + {{{UINT64_MAX, 1}}, {{3, 4}}, 4}, + + // Multiple bytes, starting in the middle + {{{1, 2}}, {{1, 3}}, 4}, // fully in the middle + {{{1, 3}}, {{1, 4}}, 4}, // to the end + {{{2, 21}}, {{2, 4}}, 4}, // overlong + {{{1, UINT64_MAX}}, {{1, 4}}, 4}}; // overlong +// TODO(toddmgreer): Before enabling support for multi-range requests, test it. + +class CreateAdjustedRangeDetailsTest : public testing::TestWithParam {}; + +TEST_P(CreateAdjustedRangeDetailsTest, All) { + RangeDetails result = + RangeUtils::createAdjustedRangeDetails(GetParam().request, GetParam().content_length); + ASSERT_TRUE(result.satisfiable_); + EXPECT_THAT(result.ranges_, testing::ContainerEq(GetParam().result)); +} + +INSTANTIATE_TEST_SUITE_P(CreateAdjustedRangeDetailsTest, CreateAdjustedRangeDetailsTest, + testing::ValuesIn(satisfiable_ranges)); + +class AdjustByteRangeUnsatisfiableTest : public testing::TestWithParam> { +}; + +std::vector unsatisfiable_ranges[] = { + {{4, 5}}, + {{4, 9}}, + {{7, UINT64_MAX}}, + {{UINT64_MAX, 0}}, +}; + +TEST_P(AdjustByteRangeUnsatisfiableTest, All) { + RangeDetails result = RangeUtils::createAdjustedRangeDetails(GetParam(), 3); + ASSERT_FALSE(result.satisfiable_); +} + +INSTANTIATE_TEST_SUITE_P(AdjustByteRangeUnsatisfiableTest, AdjustByteRangeUnsatisfiableTest, + testing::ValuesIn(unsatisfiable_ranges)); + +TEST(AdjustByteRange, NoRangeRequest) { + RangeDetails result = RangeUtils::createAdjustedRangeDetails({}, 8); + ASSERT_TRUE(result.satisfiable_); + EXPECT_THAT(result.ranges_, testing::ContainerEq(std::vector{})); +} + +TEST(ParseRangeHeaderTest, InvalidUnit) { + absl::optional> result = RangeUtils::parseRangeHeader("bits=3-4", 5); + + ASSERT_FALSE(result.has_value()); +} + +TEST(ParseRangeHeaderTest, SingleRange) { + absl::optional> result = RangeUtils::parseRangeHeader("bytes=3-4", 5); + + ASSERT_TRUE(result.has_value()); + auto result_vector = result.value(); + + ASSERT_EQ(1, result_vector.size()); + + EXPECT_EQ(3, result_vector[0].firstBytePos()); + EXPECT_EQ(4, result_vector[0].lastBytePos()); +} + +TEST(ParseRangeHeaderTest, MissingFirstBytePos) { + absl::optional> result = RangeUtils::parseRangeHeader("bytes=-5", 5); + + ASSERT_TRUE(result.has_value()); + auto result_vector = result.value(); + ASSERT_EQ(1, result_vector.size()); + + EXPECT_TRUE(result_vector[0].isSuffix()); + EXPECT_EQ(5, result_vector[0].suffixLength()); +} + +TEST(ParseRangeHeaderTest, MissingLastBytePos) { + absl::optional> result = RangeUtils::parseRangeHeader("bytes=6-", 5); + + ASSERT_TRUE(result.has_value()); + auto result_vector = result.value(); + + ASSERT_EQ(1, result_vector.size()); + + EXPECT_EQ(6, result_vector[0].firstBytePos()); + EXPECT_EQ(std::numeric_limits::max(), result_vector[0].lastBytePos()); +} + +TEST(ParseRangeHeaderTest, MultipleRanges) { + absl::optional> result = + RangeUtils::parseRangeHeader("bytes=345-456,-567,6789-", 5); + + ASSERT_TRUE(result.has_value()); + auto result_vector = result.value(); + + ASSERT_EQ(3, result_vector.size()); + + EXPECT_EQ(345, result_vector[0].firstBytePos()); + EXPECT_EQ(456, result_vector[0].lastBytePos()); + + EXPECT_TRUE(result_vector[1].isSuffix()); + EXPECT_EQ(567, result_vector[1].suffixLength()); + + EXPECT_EQ(6789, result_vector[2].firstBytePos()); + EXPECT_EQ(UINT64_MAX, result_vector[2].lastBytePos()); +} + +TEST(ParseRangeHeaderTest, LongRangeHeaderValue) { + absl::string_view header_value = "bytes=1000-1000,1001-1001,1002-1002,1003-1003,1004-1004,1005-" + "1005,1006-1006,1007-1007,1008-1008,100-"; + absl::optional> result = RangeUtils::parseRangeHeader(header_value, 10); + + ASSERT_TRUE(result.has_value()); + auto result_vector = result.value(); + + ASSERT_EQ(10, result_vector.size()); +} + +TEST(ParseRangeHeaderTest, ZeroRangeLimit) { + absl::optional> result = + RangeUtils::parseRangeHeader("bytes=1000-1000", 0); + + ASSERT_FALSE(result.has_value()); +} + +TEST(ParseRangeHeaderTest, OverRangeLimit) { + absl::optional> result = + RangeUtils::parseRangeHeader("bytes=1000-1000,1001-1001", 1); + + ASSERT_FALSE(result.has_value()); +} + +class ParseInvalidRangeHeaderTest : public testing::Test, + public testing::WithParamInterface { +protected: + absl::string_view headerValue() { return GetParam(); } +}; + +// clang-format off +INSTANTIATE_TEST_SUITE_P( + Default, ParseInvalidRangeHeaderTest, + testing::Values("-", + "1-2", + "12", + "a", + "a1", + "bytes=", + "bytes=-", + "bytes1-2", + "bytes=12", + "bytes=1-2-3", + "bytes=1-2-", + "bytes=1--3", + "bytes=--2", + "bytes=2--", + "bytes=-2-", + "bytes=-1-2", + "bytes=a-2", + "bytes=2-a", + "bytes=-a", + "bytes=a-", + "bytes=a1-2", + "bytes=1-a2", + "bytes=1a-2", + "bytes=1-2a", + "bytes=1-2,3-a", + "bytes=1-a,3-4", + "bytes=1-2,3a-4", + "bytes=1-2,3-4a", + "bytes=1-2,3-4-5", + "bytes=1-2,bytes=3-4", + "bytes=1-2,3-4,a", + // negative length + "bytes=2-1", + // too many byte ranges (test sets the limit as 5) + "bytes=0-1,1-2,2-3,3-4,4-5,5-6", + // UINT64_MAX-UINT64_MAX+1 + "bytes=18446744073709551615-18446744073709551616", + // UINT64_MAX+1-UINT64_MAX+2 + "bytes=18446744073709551616-18446744073709551617")); +// clang-format on + +TEST_P(ParseInvalidRangeHeaderTest, InvalidRangeReturnsEmpty) { + absl::optional> result = RangeUtils::parseRangeHeader(headerValue(), 5); + ASSERT_FALSE(result.has_value()); +} + +TEST(CreateRangeDetailsTest, NoRangeHeader) { + Envoy::Http::TestRequestHeaderMapImpl headers = + Envoy::Http::TestRequestHeaderMapImpl{{":method", "GET"}}; + absl::optional result = RangeUtils::createRangeDetails(headers, 5); + + ASSERT_FALSE(result.has_value()); +} + +TEST(CreateRangeDetailsTest, SingleSatisfiableRange) { + Envoy::Http::TestRequestHeaderMapImpl request_headers{{":path", "/"}, + {":method", "GET"}, + {"x-forwarded-proto", "https"}, + {":authority", "example.com"}, + {"range", "bytes=1-99"}}; + const Envoy::Http::HeaderMap::GetResult range_header = + request_headers.get(Envoy::Http::Headers::get().Range); + absl::optional result = RangeUtils::createRangeDetails(request_headers, 4); + ASSERT_TRUE(result.has_value()); + RangeDetails& spec = result.value(); + EXPECT_TRUE(spec.satisfiable_); + ASSERT_EQ(spec.ranges_.size(), 1); + + AdjustedByteRange& range = spec.ranges_[0]; + EXPECT_EQ(range.begin(), 1); + EXPECT_EQ(range.end(), 4); + EXPECT_EQ(range.length(), 3); +} + +TEST(GetRangeDetailsTest, MultipleSatisfiableRanges) { + // Because we do not support multi-part responses for now, we are limiting + // parsing of a single range, so we return false to indicate to the + // CacheFilter that the request should be handled as if this were not a range + // request. + + Envoy::Http::TestRequestHeaderMapImpl request_headers{{":path", "/"}, + {":method", "GET"}, + {"x-forwarded-proto", "https"}, + {":authority", "example.com"}, + {"range", "bytes=1-99,3-,-3"}}; + + absl::optional result = RangeUtils::createRangeDetails(request_headers, 4); + + EXPECT_FALSE(result.has_value()); +} + +TEST(GetRangeDetailsTest, NotSatisfiableRange) { + Envoy::Http::TestRequestHeaderMapImpl request_headers{{":path", "/"}, + {":method", "GET"}, + {"x-forwarded-proto", "https"}, + {":authority", "example.com"}, + {"range", "bytes=100-"}}; + + absl::optional result = RangeUtils::createRangeDetails(request_headers, 4); + ASSERT_TRUE(result.has_value()); + EXPECT_FALSE(result->satisfiable_); + ASSERT_TRUE(result->ranges_.empty()); +} + +// operator<<(ostream&, const AdjustedByteRange&) is only used in tests, but lives in //source, +// and so needs test coverage. This test provides that coverage, to keep the coverage test happy. +TEST(AdjustedByteRange, StreamingTest) { + std::ostringstream os; + os << AdjustedByteRange(0, 1); + EXPECT_EQ(os.str(), "[0,1)"); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/stats_test.cc b/test/extensions/filters/http/cache_v2/stats_test.cc new file mode 100644 index 0000000000000..5cad0d5516b87 --- /dev/null +++ b/test/extensions/filters/http/cache_v2/stats_test.cc @@ -0,0 +1,117 @@ +#include + +#include "source/extensions/filters/http/cache_v2/stats.h" + +#include "test/mocks/server/factory_context.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +class CacheStatsTest : public ::testing::Test { +protected: + NiceMock context_; + std::unique_ptr stats_ = generateStats(context_.scope(), "fake.cache"); +}; + +MATCHER_P(OptCounterHasValue, m, "") { + return testing::ExplainMatchResult( + testing::Optional( + testing::Property("get", &std::reference_wrapper::get, + testing::Property("value", &Envoy::Stats::Counter::value, m))), + arg, result_listener); +} + +MATCHER_P(OptGaugeHasValue, m, "") { + return testing::ExplainMatchResult( + testing::Optional( + testing::Property("get", &std::reference_wrapper::get, + testing::Property("value", &Envoy::Stats::Gauge::value, m))), + arg, result_listener); +} + +MATCHER_P(OptCounterHasName, m, "") { + return testing::ExplainMatchResult( + testing::Optional(testing::Property( + "get", &std::reference_wrapper::get, + testing::Property("tagExtractedName", &Envoy::Stats::Counter::tagExtractedName, m))), + arg, result_listener); +} + +MATCHER_P2(OptCounterIs, name, value, "") { + return testing::ExplainMatchResult( + testing::AllOf(OptCounterHasName(name), OptCounterHasValue(value)), arg, result_listener); +} + +TEST_F(CacheStatsTest, StatsAreConstructedCorrectly) { + // 4 for hit + stats_->incForStatus(CacheEntryStatus::Hit); + stats_->incForStatus(CacheEntryStatus::FoundNotModified); + stats_->incForStatus(CacheEntryStatus::Follower); + stats_->incForStatus(CacheEntryStatus::ValidatedFree); + Stats::CounterOptConstRef hits = + context_.store_.findCounterByString("cache.event.cache_label.fake_cache.event_type.hit"); + EXPECT_THAT(hits, OptCounterIs("cache.event", 4)); + // 1 for miss + stats_->incForStatus(CacheEntryStatus::Miss); + Stats::CounterOptConstRef misses = + context_.store_.findCounterByString("cache.event.cache_label.fake_cache.event_type.miss"); + EXPECT_THAT(misses, OptCounterIs("cache.event", 1)); + // 1 for failed validation + stats_->incForStatus(CacheEntryStatus::FailedValidation); + Stats::CounterOptConstRef failed_validations = context_.store_.findCounterByString( + "cache.event.cache_label.fake_cache.event_type.failed_validation"); + EXPECT_THAT(failed_validations, OptCounterIs("cache.event", 1)); + // 1 for validated + stats_->incForStatus(CacheEntryStatus::Validated); + Stats::CounterOptConstRef validates = + context_.store_.findCounterByString("cache.event.cache_label.fake_cache.event_type.validate"); + EXPECT_THAT(validates, OptCounterIs("cache.event", 1)); + + stats_->incForStatus(CacheEntryStatus::Uncacheable); + Stats::CounterOptConstRef uncacheables = context_.store_.findCounterByString( + "cache.event.cache_label.fake_cache.event_type.uncacheable"); + EXPECT_THAT(uncacheables, OptCounterIs("cache.event", 1)); + + stats_->incForStatus(CacheEntryStatus::UpstreamReset); + Stats::CounterOptConstRef upstream_resets = context_.store_.findCounterByString( + "cache.event.cache_label.fake_cache.event_type.upstream_reset"); + EXPECT_THAT(upstream_resets, OptCounterIs("cache.event", 1)); + + stats_->incForStatus(CacheEntryStatus::LookupError); + Stats::CounterOptConstRef lookup_errors = context_.store_.findCounterByString( + "cache.event.cache_label.fake_cache.event_type.lookup_error"); + EXPECT_THAT(lookup_errors, OptCounterIs("cache.event", 1)); + + stats_->incCacheSessionsEntries(); + stats_->incCacheSessionsEntries(); + stats_->incCacheSessionsEntries(); + stats_->decCacheSessionsEntries(); + Stats::GaugeOptConstRef cache_sessions_entries = + context_.store_.findGaugeByString("cache.cache_sessions_entries.cache_label.fake_cache"); + EXPECT_THAT(cache_sessions_entries, OptGaugeHasValue(2)); + + stats_->incCacheSessionsSubscribers(); + stats_->incCacheSessionsSubscribers(); + stats_->incCacheSessionsSubscribers(); + stats_->subCacheSessionsSubscribers(2); + Stats::GaugeOptConstRef cache_sessions_subscribers = + context_.store_.findGaugeByString("cache.cache_sessions_subscribers.cache_label.fake_cache"); + EXPECT_THAT(cache_sessions_subscribers, OptGaugeHasValue(1)); + + stats_->addUpstreamBufferedBytes(1024); + stats_->subUpstreamBufferedBytes(512); + Stats::GaugeOptConstRef upstream_buffered_bytes = + context_.store_.findGaugeByString("cache.upstream_buffered_bytes.cache_label.fake_cache"); + EXPECT_THAT(upstream_buffered_bytes, OptGaugeHasValue(512)); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache_v2/upstream_request_test.cc b/test/extensions/filters/http/cache_v2/upstream_request_test.cc new file mode 100644 index 0000000000000..b97b4f0c569fa --- /dev/null +++ b/test/extensions/filters/http/cache_v2/upstream_request_test.cc @@ -0,0 +1,302 @@ +#include "source/extensions/filters/http/cache_v2/upstream_request_impl.h" + +#include "test/extensions/filters/http/cache_v2/mocks.h" +#include "test/mocks/http/mocks.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +using testing::_; +using testing::IsNull; +using testing::MockFunction; +using testing::Pointee; + +class UpstreamRequestTest : public ::testing::Test { +protected: + // Arbitrary buffer limit for testing. + virtual int bufferLimit() const { return 1024; } + void SetUp() override { + EXPECT_CALL(async_client_, start(_, _)) + .WillOnce([this](Http::AsyncClient::StreamCallbacks& callbacks, + const Http::AsyncClient::StreamOptions&) { + http_callbacks_ = &callbacks; + return &http_stream_; + }); + EXPECT_CALL(http_stream_, sendHeaders(HeaderMapEqualRef(&request_headers_), true)); + Http::AsyncClient::StreamOptions options; + options.setBufferLimit(bufferLimit()); + EXPECT_CALL(dispatcher_, isThreadSafe()) + .Times(testing::AnyNumber()) + .WillRepeatedly(testing::Return(true)); + upstream_request_ = + UpstreamRequestImplFactory(dispatcher_, async_client_, options).create(stats_provider_); + upstream_request_->sendHeaders( + Http::createHeaderMap(request_headers_)); + } + +protected: + Event::MockDispatcher dispatcher_; + Http::AsyncClient::StreamCallbacks* http_callbacks_; + Http::MockAsyncClientStream http_stream_; + Http::MockAsyncClient async_client_; + Http::TestRequestHeaderMapImpl request_headers_{{":method", "GET"}, {":path", "/banana"}}; + std::shared_ptr stats_provider_ = + std::make_shared>(); + UpstreamRequestPtr upstream_request_; + Http::TestResponseHeaderMapImpl response_headers_{{":status", "200"}}; + Http::TestResponseTrailerMapImpl response_trailers_{{"x", "y"}}; +}; + +TEST_F(UpstreamRequestTest, ResetBeforeHeadersRequestedDeliversResetToCallback) { + MockFunction header_cb; + http_callbacks_->onReset(); + EXPECT_CALL(header_cb, Call(IsNull(), EndStream::Reset)); + upstream_request_->getHeaders(header_cb.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, ResetBeforeHeadersArrivedDeliversResetToCallback) { + MockFunction header_cb; + upstream_request_->getHeaders(header_cb.AsStdFunction()); + EXPECT_CALL(header_cb, Call(IsNull(), EndStream::Reset)); + http_callbacks_->onReset(); +} + +TEST_F(UpstreamRequestTest, HeadersArrivedThenRequestedDeliversHeaders) { + MockFunction header_cb; + http_callbacks_->onHeaders(std::make_unique(response_headers_), + false); + EXPECT_CALL(header_cb, Call(HeaderMapEqualIgnoreOrder(&response_headers_), EndStream::More)); + upstream_request_->getHeaders(header_cb.AsStdFunction()); + EXPECT_CALL(http_stream_, reset()); +} + +TEST_F(UpstreamRequestTest, HeadersRequestedThenArrivedDeliversHeaders) { + MockFunction header_cb; + upstream_request_->getHeaders(header_cb.AsStdFunction()); + EXPECT_CALL(header_cb, Call(HeaderMapEqualIgnoreOrder(&response_headers_), EndStream::More)); + http_callbacks_->onHeaders(std::make_unique(response_headers_), + false); + EXPECT_CALL(http_stream_, reset()); +} + +TEST_F(UpstreamRequestTest, HeadersEndStreamWorksAndPreventsReset) { + MockFunction header_cb; + upstream_request_->getHeaders(header_cb.AsStdFunction()); + EXPECT_CALL(header_cb, Call(HeaderMapEqualIgnoreOrder(&response_headers_), EndStream::End)); + http_callbacks_->onHeaders(std::make_unique(response_headers_), + true); + http_callbacks_->onComplete(); +} + +TEST_F(UpstreamRequestTest, ResetBeforeBodyRequestedDeliversResetToCallback) { + MockFunction body_cb; + http_callbacks_->onReset(); + EXPECT_CALL(body_cb, Call(IsNull(), EndStream::Reset)); + upstream_request_->getBody(AdjustedByteRange{0, 5}, body_cb.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, ResetAfterBodyRequestedDeliversResetToCallback) { + MockFunction body_cb; + upstream_request_->getBody(AdjustedByteRange{0, 5}, body_cb.AsStdFunction()); + EXPECT_CALL(body_cb, Call(IsNull(), EndStream::Reset)); + http_callbacks_->onReset(); +} + +TEST_F(UpstreamRequestTest, BodyRequestedThenArrivedDeliversBody) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb; + upstream_request_->getBody(AdjustedByteRange{0, 5}, body_cb.AsStdFunction()); + EXPECT_CALL(body_cb, Call(Pointee(BufferStringEqual("hello")), EndStream::End)); + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); +} + +TEST_F(UpstreamRequestTest, BodyArrivedThenOversizedRequestedDeliversBody) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb; + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); + EXPECT_CALL(body_cb, Call(Pointee(BufferStringEqual("hello")), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{0, 99}, body_cb.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, BodyArrivedThenRequestedInPiecesDeliversBody) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb1; + MockFunction body_cb2; + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); + EXPECT_CALL(body_cb1, Call(Pointee(BufferStringEqual("hel")), EndStream::More)); + upstream_request_->getBody(AdjustedByteRange{0, 3}, body_cb1.AsStdFunction()); + EXPECT_CALL(body_cb2, Call(Pointee(BufferStringEqual("lo")), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{3, 5}, body_cb2.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, BodyAlternatingActionsDeliversBody) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb1; + MockFunction body_cb2; + upstream_request_->getBody(AdjustedByteRange{0, 3}, body_cb1.AsStdFunction()); + EXPECT_CALL(body_cb1, Call(Pointee(BufferStringEqual("hel")), EndStream::More)); + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); + EXPECT_CALL(body_cb2, Call(Pointee(BufferStringEqual("lo")), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{3, 5}, body_cb2.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, BodyInMultiplePiecesDeliversBody) { + Buffer::OwnedImpl data1{"hello"}; + Buffer::OwnedImpl data2{"there"}; + Buffer::OwnedImpl data3{"banana"}; + MockFunction body_cb1; + MockFunction body_cb2; + upstream_request_->getBody(AdjustedByteRange{0, 99}, body_cb1.AsStdFunction()); + EXPECT_CALL(body_cb1, Call(Pointee(BufferStringEqual("hello")), EndStream::More)); + http_callbacks_->onData(data1, false); + http_callbacks_->onData(data2, false); + http_callbacks_->onData(data3, true); + http_callbacks_->onComplete(); + EXPECT_CALL(body_cb2, Call(Pointee(BufferStringEqual("therebanana")), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{5, 99}, body_cb2.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, DeletionWhileBodyCallbackInFlightCallsReset) { + MockFunction body_cb; + upstream_request_->getBody(AdjustedByteRange{0, 99}, body_cb.AsStdFunction()); + EXPECT_CALL(http_stream_, reset()); +} + +TEST_F(UpstreamRequestTest, RequestingMoreBodyAfterCompletionReturnsNull) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb1; + MockFunction body_cb2; + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); + EXPECT_CALL(body_cb1, Call(Pointee(BufferStringEqual("hello")), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{0, 99}, body_cb1.AsStdFunction()); + EXPECT_CALL(body_cb2, Call(IsNull(), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{5, 99}, body_cb2.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, RequestingMoreBodyAfterTrailersResumesAndEventuallyReturnsNull) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb1; + MockFunction body_cb2; + MockFunction body_cb3; + MockFunction trailers_cb; + http_callbacks_->onData(data, false); + http_callbacks_->onTrailers( + std::make_unique(response_trailers_)); + http_callbacks_->onComplete(); + EXPECT_CALL(body_cb1, Call(Pointee(BufferStringEqual("hel")), EndStream::More)); + upstream_request_->getBody(AdjustedByteRange{0, 3}, body_cb1.AsStdFunction()); + EXPECT_CALL(body_cb2, Call(Pointee(BufferStringEqual("lo")), EndStream::More)); + upstream_request_->getBody(AdjustedByteRange{3, 99}, body_cb2.AsStdFunction()); + EXPECT_CALL(body_cb3, Call(IsNull(), EndStream::More)); + upstream_request_->getBody(AdjustedByteRange{5, 99}, body_cb3.AsStdFunction()); + EXPECT_CALL(trailers_cb, Call(HeaderMapEqualIgnoreOrder(&response_trailers_), EndStream::End)); + upstream_request_->getTrailers(trailers_cb.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, ResetBeforeTrailersRequestedDeliversResetToCallback) { + MockFunction trailer_cb; + http_callbacks_->onReset(); + EXPECT_CALL(trailer_cb, Call(IsNull(), EndStream::Reset)); + upstream_request_->getTrailers(trailer_cb.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, ResetBeforeTrailersArrivedDeliversResetToCallback) { + MockFunction trailer_cb; + upstream_request_->getTrailers(trailer_cb.AsStdFunction()); + EXPECT_CALL(trailer_cb, Call(IsNull(), EndStream::Reset)); + http_callbacks_->onReset(); +} + +TEST_F(UpstreamRequestTest, TrailersArrivedThenRequestedDeliversTrailers) { + MockFunction trailer_cb; + http_callbacks_->onTrailers( + std::make_unique(response_trailers_)); + http_callbacks_->onComplete(); + EXPECT_CALL(trailer_cb, Call(HeaderMapEqualIgnoreOrder(&response_trailers_), EndStream::End)); + upstream_request_->getTrailers(trailer_cb.AsStdFunction()); +} + +TEST_F(UpstreamRequestTest, TrailersRequestedThenArrivedDeliversTrailers) { + MockFunction trailer_cb; + upstream_request_->getTrailers(trailer_cb.AsStdFunction()); + EXPECT_CALL(trailer_cb, Call(HeaderMapEqualIgnoreOrder(&response_trailers_), EndStream::End)); + http_callbacks_->onTrailers( + std::make_unique(response_trailers_)); + http_callbacks_->onComplete(); +} + +TEST_F(UpstreamRequestTest, TrailersArrivedWhileExpectingMoreBodyDeliversNullBodyThenTrailers) { + MockFunction body_cb; + MockFunction trailer_cb; + EXPECT_CALL(body_cb, Call(IsNull(), EndStream::More)); + upstream_request_->getBody(AdjustedByteRange{0, 5}, body_cb.AsStdFunction()); + http_callbacks_->onTrailers( + std::make_unique(response_trailers_)); + testing::Mock::VerifyAndClearExpectations(&body_cb); + EXPECT_CALL(trailer_cb, Call(HeaderMapEqualIgnoreOrder(&response_trailers_), EndStream::End)); + upstream_request_->getTrailers(trailer_cb.AsStdFunction()); + http_callbacks_->onComplete(); +} + +TEST_F(UpstreamRequestTest, DestroyedWhileBodyBufferedCorrectsStats) { + Buffer::OwnedImpl data{"hello"}; + EXPECT_CALL(stats_provider_->mock_stats_, addUpstreamBufferedBytes(data.length())); + EXPECT_CALL(http_stream_, reset()); + EXPECT_CALL(stats_provider_->mock_stats_, subUpstreamBufferedBytes(data.length())); + http_callbacks_->onData(data, true); + upstream_request_.reset(); +} + +class UpstreamRequestWithRangeHeaderTest : public UpstreamRequestTest { +protected: + void SetUp() override { + request_headers_.addCopy("range", "bytes=3-4"); + UpstreamRequestTest::SetUp(); + } +}; + +TEST_F(UpstreamRequestWithRangeHeaderTest, RangeHeaderSkipsToExpectedStreamPos) { + Buffer::OwnedImpl data{"lo"}; + MockFunction body_cb; + upstream_request_->getBody(AdjustedByteRange{3, 5}, body_cb.AsStdFunction()); + EXPECT_CALL(body_cb, Call(Pointee(BufferStringEqual("lo")), EndStream::End)); + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); +} + +class UpstreamRequestWithSmallBuffersTest : public UpstreamRequestTest { +protected: + int bufferLimit() const override { return 3; } +}; + +TEST_F(UpstreamRequestWithSmallBuffersTest, WatermarksPauseTheUpstream) { + Buffer::OwnedImpl data{"hello"}; + MockFunction body_cb; + // TODO(ravenblack): validate that onAboveHighWatermark actions + // are performed during onData, once it's possible to pause flow + // from upstream. + http_callbacks_->onData(data, true); + http_callbacks_->onComplete(); + // TODO(ravenblack): validate that onBelowHighWatermark actions + // are performed during onData, once it's possible to pause flow + // from upstream. + EXPECT_CALL(body_cb, Call(Pointee(BufferStringEqual("hello")), EndStream::End)); + upstream_request_->getBody(AdjustedByteRange{0, 5}, body_cb.AsStdFunction()); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/http/cache_v2/file_system_http_cache/BUILD b/test/extensions/http/cache_v2/file_system_http_cache/BUILD new file mode 100644 index 0000000000000..bba26a8bdcedb --- /dev/null +++ b/test/extensions/http/cache_v2/file_system_http_cache/BUILD @@ -0,0 +1,47 @@ +load("//bazel:envoy_build_system.bzl", "envoy_cc_test", "envoy_package") +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "file_system_http_cache_test", + srcs = ["file_system_http_cache_test.cc"], + extension_names = ["envoy.extensions.http.cache_v2.file_system_http_cache"], + rbe_pool = "6gig", + tags = ["skip_on_windows"], # async_files does not yet support Windows. + deps = [ + "//source/common/filesystem:directory_lib", + "//source/extensions/filters/http/cache_v2:cache_entry_utils_lib", + "//source/extensions/http/cache_v2/file_system_http_cache:config", + "//test/extensions/common/async_files:mocks", + "//test/extensions/filters/http/cache_v2:http_cache_implementation_test_common_lib", + "//test/extensions/filters/http/cache_v2:mocks", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:status_utility_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_cc_test( + name = "cache_file_header_proto_util_test", + srcs = ["cache_file_header_proto_util_test.cc"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/http/cache_v2/file_system_http_cache:cache_file_header_proto_util", + ], +) + +envoy_cc_test( + name = "cache_file_fixed_block_test", + srcs = ["cache_file_fixed_block_test.cc"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/http/cache_v2/file_system_http_cache:cache_file_fixed_block", + ], +) diff --git a/test/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block_test.cc b/test/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block_test.cc new file mode 100644 index 0000000000000..03959934b0737 --- /dev/null +++ b/test/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block_test.cc @@ -0,0 +1,91 @@ +#include "source/common/buffer/buffer_impl.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +class CacheFileFixedBlockTest : public ::testing::Test {}; + +namespace { + +TEST_F(CacheFileFixedBlockTest, InitializesToValid) { + CacheFileFixedBlock default_block; + EXPECT_TRUE(default_block.isValid()); +} + +TEST_F(CacheFileFixedBlockTest, IsValidReturnsFalseOnBadFileId) { + CacheFileFixedBlock block; + Buffer::OwnedImpl buffer; + block.serializeToBuffer(buffer); + for (int i = 0; i < 4; i++) { + std::string serialized = buffer.toString(); + // Any file id other than the current compile time constant should be invalid. + serialized[i] = serialized[i] + 1; + block.populateFromStringView(serialized); + EXPECT_FALSE(block.isValid()); + } +} + +TEST_F(CacheFileFixedBlockTest, IsValidReturnsFalseOnBadCacheVersionId) { + CacheFileFixedBlock block; + Buffer::OwnedImpl buffer; + block.serializeToBuffer(buffer); + for (int i = 4; i < 8; i++) { + std::string serialized = buffer.toString(); + // Any cache version id other than the current compile time constant should be invalid. + serialized[i] = serialized[i] + 1; + block.populateFromStringView(serialized); + EXPECT_FALSE(block.isValid()); + } +} + +TEST_F(CacheFileFixedBlockTest, IsValidReturnsTrueOnBlockWithNonDefaultSizes) { + CacheFileFixedBlock block; + block.setHeadersSize(1234); + block.setBodySize(999999); + block.setTrailersSize(4321); + EXPECT_TRUE(block.isValid()); +} + +TEST_F(CacheFileFixedBlockTest, ReturnsCorrectOffsets) { + CacheFileFixedBlock block; + block.setHeadersSize(100); + block.setBodySize(1000); + block.setTrailersSize(10); + EXPECT_EQ(block.offsetToHeaders(), CacheFileFixedBlock::size() + 1010); + EXPECT_EQ(block.offsetToBody(), CacheFileFixedBlock::size()); + EXPECT_EQ(block.offsetToTrailers(), CacheFileFixedBlock::size() + 1000); +} + +TEST_F(CacheFileFixedBlockTest, SerializesAndDeserializesCorrectly) { + CacheFileFixedBlock block; + // A body size that doesn't fit in a uint32, to ensure large numbers also serialize. + constexpr uint64_t billion = 1000 * 1000 * 1000; + constexpr uint64_t large_body_size = 10 * billion; + block.setHeadersSize(100); + block.setBodySize(large_body_size); + block.setTrailersSize(10); + CacheFileFixedBlock block2; + Buffer::OwnedImpl buf; + block.serializeToBuffer(buf); + block2.populateFromStringView(buf.toString()); + EXPECT_TRUE(block2.isValid()); + EXPECT_EQ(block2.offsetToHeaders(), CacheFileFixedBlock::size() + large_body_size + 10); + EXPECT_EQ(block2.offsetToBody(), CacheFileFixedBlock::size()); + EXPECT_EQ(block2.offsetToTrailers(), CacheFileFixedBlock::size() + large_body_size); + EXPECT_EQ(block2.headerSize(), 100); + EXPECT_EQ(block2.bodySize(), large_body_size); + EXPECT_EQ(block2.trailerSize(), 10); +} + +} // namespace +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util_test.cc b/test/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util_test.cc new file mode 100644 index 0000000000000..50861d84dd209 --- /dev/null +++ b/test/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util_test.cc @@ -0,0 +1,191 @@ +#include "envoy/http/header_map.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header.pb.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { +namespace { + +constexpr char test_header_proto[] = R"( + key: + host: "banana" + metadata_response_time: + seconds: 1234 + headers: + - key: "test_header" + value: "test_value" + - key: "second_header" + value: "second_value" + - key: "second_header" + value: "additional_value" +)"; + +constexpr char test_trailer_proto[] = R"( + trailers: + - key: "test_trailer" + value: "test_value" + - key: "second_trailer" + value: "second_value" + - key: "second_trailer" + value: "additional_value" +)"; + +TEST(CacheFileHeaderProtoUtil, MakeCacheFileHeaderProtoFromHeadersAndMetadata) { + Http::TestResponseHeaderMapImpl headers{ + {"test_header", "test_value"}, + {"second_header", "second_value"}, + {"second_header", "additional_value"}, + }; + ResponseMetadata metadata{Envoy::SystemTime{std::chrono::seconds{1234}}}; + Key key; + key.set_host("banana"); + CacheFileHeader result = makeCacheFileHeaderProto(key, headers, metadata); + CacheFileHeader expected; + TestUtility::loadFromYaml(test_header_proto, expected); + EXPECT_THAT(result, ProtoEqIgnoreRepeatedFieldOrdering(expected)); +} + +TEST(CacheFileHeaderProtoUtil, MakeCacheFileTrailerProto) { + Http::TestResponseTrailerMapImpl trailers{ + {"test_trailer", "test_value"}, + {"second_trailer", "second_value"}, + {"second_trailer", "additional_value"}, + }; + CacheFileTrailer result = makeCacheFileTrailerProto(trailers); + CacheFileTrailer expected; + TestUtility::loadFromYaml(test_trailer_proto, expected); + EXPECT_THAT(result, ProtoEqIgnoreRepeatedFieldOrdering(expected)); +} + +TEST(CacheFileHeaderProtoUtil, HeaderProtoSize) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + std::string serialized = header_proto.SerializeAsString(); + EXPECT_EQ(serialized.size(), headerProtoSize(header_proto)); +} + +TEST(CacheFileHeaderProtoUtil, BufferFromProtoForHeader) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + std::string serialized = header_proto.SerializeAsString(); + Buffer::OwnedImpl buffer = bufferFromProto(header_proto); + EXPECT_EQ(serialized, buffer.toString()); +} + +TEST(CacheFileHeaderProtoUtil, BufferFromProtoForTrailer) { + CacheFileTrailer trailer_proto; + TestUtility::loadFromYaml(test_trailer_proto, trailer_proto); + std::string serialized = trailer_proto.SerializeAsString(); + Buffer::OwnedImpl buffer = bufferFromProto(trailer_proto); + EXPECT_EQ(serialized, buffer.toString()); +} + +TEST(CacheFileHeaderProtoUtil, SerializedStringFromProto) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + std::string serialized = header_proto.SerializeAsString(); + std::string serialized_through_helper = serializedStringFromProto(header_proto); + EXPECT_EQ(serialized, serialized_through_helper); +} + +TEST(CacheFileHeaderProtoUtil, HeadersFromHeaderProto) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + Http::ResponseHeaderMapPtr headers = headersFromHeaderProto(header_proto); + Http::TestResponseHeaderMapImpl expected{ + {"test_header", "test_value"}, + {"second_header", "second_value"}, + {"second_header", "additional_value"}, + }; + EXPECT_THAT(headers, HeaderMapEqualIgnoreOrder(&expected)); +} + +TEST(CacheFileHeaderProtoUtil, TrailersFromTrailerProto) { + CacheFileTrailer trailer_proto; + TestUtility::loadFromYaml(test_trailer_proto, trailer_proto); + Http::ResponseTrailerMapPtr trailers = trailersFromTrailerProto(trailer_proto); + Http::TestResponseTrailerMapImpl expected{ + {"test_trailer", "test_value"}, + {"second_trailer", "second_value"}, + {"second_trailer", "additional_value"}, + }; + EXPECT_THAT(trailers, HeaderMapEqualIgnoreOrder(&expected)); +} + +TEST(CacheFileHeaderProtoUtil, MetadataFromHeaderProto) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + ResponseMetadata metadata = metadataFromHeaderProto(header_proto); + EXPECT_EQ(metadata.response_time_, Envoy::SystemTime{std::chrono::seconds{1234}}); +} + +TEST(CacheFileHeaderProtoUtil, MakeCacheFileHeaderProtoFromBuffer) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + Buffer::OwnedImpl buffer = bufferFromProto(header_proto); + CacheFileHeader header_proto_from_buffer = makeCacheFileHeaderProto(buffer); + EXPECT_THAT(header_proto, ProtoEqIgnoreRepeatedFieldOrdering(header_proto_from_buffer)); +} + +TEST(CacheFileHeaderProtoUtil, MakeCacheFileTrailerProtoFromBuffer) { + CacheFileTrailer trailer_proto; + TestUtility::loadFromYaml(test_trailer_proto, trailer_proto); + Buffer::OwnedImpl buffer = bufferFromProto(trailer_proto); + CacheFileTrailer trailer_proto_from_buffer = makeCacheFileTrailerProto(buffer); + EXPECT_THAT(trailer_proto, ProtoEqIgnoreRepeatedFieldOrdering(trailer_proto_from_buffer)); +} + +TEST(CacheFileHeaderProtoUtil, UpdateProtoFromHeadersAndMetadata) { + CacheFileHeader header_proto; + TestUtility::loadFromYaml(test_header_proto, header_proto); + Http::TestResponseHeaderMapImpl new_headers{ + {"second_header", "new_second_value"}, + {"second_header", "additional_second_value"}, + {"third_header", "third_value"}, + {"etag", "should_be_ignored"}, + }; + ResponseMetadata new_metadata{Envoy::SystemTime{std::chrono::seconds{12345}}}; + CacheFileHeader result_header_proto = + mergeProtoWithHeadersAndMetadata(header_proto, new_headers, new_metadata); + CacheFileHeader expected_header_proto; + TestUtility::loadFromYaml(R"( + key: + host: "banana" + # metadata should have been updated. + metadata_response_time: + seconds: 12345 + headers: + # test_header should be retained from the original headers. + - key: "test_header" + value: "test_value" + # second_header should be overwritten. + - key: "second_header" + value: "new_second_value" + # added value on the same key should appear. + - key: "second_header" + value: "additional_second_value" + # added value with a new key should appear. + - key: "third_header" + value: "third_value" + # ignored keys should have been discarded. + )", + expected_header_proto); + EXPECT_THAT(result_header_proto, ProtoEqIgnoreRepeatedFieldOrdering(expected_header_proto)); +} + +} // namespace +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache_test.cc b/test/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache_test.cc new file mode 100644 index 0000000000000..e97db8f2a019a --- /dev/null +++ b/test/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache_test.cc @@ -0,0 +1,925 @@ +#include "envoy/http/header_map.h" +#include "envoy/registry/registry.h" +#include "envoy/singleton/manager.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/common/filesystem/directory.h" +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_eviction_thread.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_fixed_block.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.h" +#include "source/extensions/http/cache_v2/file_system_http_cache/file_system_http_cache.h" + +#include "test/extensions/common/async_files/mocks.h" +#include "test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.h" +#include "test/extensions/filters/http/cache_v2/mocks.h" +#include "test/mocks/server/factory_context.h" +#include "test/test_common/environment.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/status_utility.h" +#include "test/test_common/utility.h" + +#include "absl/cleanup/cleanup.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace FileSystemHttpCache { + +using Common::AsyncFiles::AsyncFileHandle; +using Common::AsyncFiles::MockAsyncFileContext; +using Common::AsyncFiles::MockAsyncFileHandle; +using Common::AsyncFiles::MockAsyncFileManager; +using Common::AsyncFiles::MockAsyncFileManagerFactory; +using ::envoy::extensions::filters::http::cache_v2::v3::CacheV2Config; +using StatusHelpers::HasStatusCode; +using StatusHelpers::IsOkAndHolds; +using ::testing::HasSubstr; +using ::testing::IsNull; +using ::testing::NiceMock; +using ::testing::Return; +using ::testing::StrictMock; + +MATCHER(PopulatedLookup, "") { return arg.populated(); } + +absl::string_view yaml_config = R"( + typed_config: + "@type": type.googleapis.com/envoy.extensions.http.cache_v2.file_system_http_cache.v3.FileSystemHttpCacheV2Config + manager_config: + thread_pool: + thread_count: 1 + cache_path: /tmp +)"; + +class FileSystemCacheTestContext { +public: + FileSystemCacheTestContext() { + cache_path_ = absl::StrCat(env_.temporaryDirectory(), "/"); + ConfigProto cfg = testConfig(); + deleteCacheFiles(cfg.cache_path()); + auto cache_config = cacheConfig(cfg); + const std::string type{ + TypeUtil::typeUrlToDescriptorFullName(cache_config.typed_config().type_url())}; + http_cache_factory_ = Registry::FactoryRegistry::getFactoryByType(type); + if (http_cache_factory_ == nullptr) { + throw EnvoyException( + fmt::format("Didn't find a registered implementation for type: '{}'", type)); + } + ON_CALL(context_.server_factory_context_.api_, threadFactory()) + .WillByDefault([]() -> Thread::ThreadFactory& { return Thread::threadFactoryForTest(); }); + } + + void initCache() { cache_ = *http_cache_factory_->getCache(cacheConfig(testConfig()), context_); } + + void waitForEvictionThreadIdle() { cache()->cache_eviction_thread_.waitForIdle(); } + + ConfigProto testConfig() { + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config cache_config; + TestUtility::loadFromYaml(std::string(yaml_config), cache_config); + ConfigProto cfg; + EXPECT_TRUE(MessageUtil::unpackTo(cache_config.typed_config(), cfg).ok()); + cfg.set_cache_path(cache_path_); + return cfg; + } + + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config cacheConfig(ConfigProto cfg) { + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config cache_config; + cache_config.mutable_typed_config()->PackFrom(cfg); + return cache_config; + } + +protected: + void deleteCacheFiles(std::string path) { + for (const auto& it : ::Envoy::Filesystem::Directory(path)) { + if (absl::StartsWith(it.name_, "cache-")) { + env_.removePath(absl::StrCat(path, it.name_)); + } + } + } + + FileSystemHttpCache* cache() { return dynamic_cast(&cache_->cache()); } + ::Envoy::TestEnvironment env_; + std::string cache_path_; + NiceMock context_; + std::shared_ptr cache_; + HttpCacheFactory* http_cache_factory_; +}; + +class FileSystemHttpCacheTestWithNoDefaultCache : public FileSystemCacheTestContext, + public ::testing::Test {}; + +TEST_F(FileSystemHttpCacheTestWithNoDefaultCache, InitialStatsAreSetCorrectly) { + const std::string file_1_contents = "XXXXX"; + const std::string file_2_contents = "YYYYYYYYYY"; + const uint64_t max_count = 99; + const uint64_t max_size = 87654321; + ConfigProto cfg = testConfig(); + cfg.mutable_max_cache_entry_count()->set_value(max_count); + cfg.mutable_max_cache_size_bytes()->set_value(max_size); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-a"), file_1_contents, true); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-b"), file_2_contents, true); + cache_ = *http_cache_factory_->getCache(cacheConfig(cfg), context_); + waitForEvictionThreadIdle(); + EXPECT_EQ(cache()->stats().size_limit_bytes_.value(), max_size); + EXPECT_EQ(cache()->stats().size_limit_count_.value(), max_count); + EXPECT_EQ(cache()->stats().size_bytes_.value(), file_1_contents.size() + file_2_contents.size()); + EXPECT_EQ(cache()->stats().size_count_.value(), 2); + EXPECT_EQ(cache()->stats().eviction_runs_.value(), 0); +} + +TEST_F(FileSystemHttpCacheTestWithNoDefaultCache, EvictsOldestFilesUntilUnderCountLimit) { + const std::string file_contents = "XXXXX"; + const uint64_t max_count = 2; + ConfigProto cfg = testConfig(); + cfg.mutable_max_cache_entry_count()->set_value(max_count); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-a"), file_contents, true); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-b"), file_contents, true); + // TODO(#24994): replace this with backdating the files when that's possible. + sleep(1); // NO_CHECK_FORMAT(real_time) + cache_ = *http_cache_factory_->getCache(cacheConfig(cfg), context_); + waitForEvictionThreadIdle(); + EXPECT_EQ(cache()->stats().eviction_runs_.value(), 0); + EXPECT_EQ(cache()->stats().size_bytes_.value(), file_contents.size() * 2); + EXPECT_EQ(cache()->stats().size_count_.value(), 2); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-c"), file_contents, true); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-d"), file_contents, true); + cache()->trackFileAdded(file_contents.size()); + cache()->trackFileAdded(file_contents.size()); + waitForEvictionThreadIdle(); + EXPECT_EQ(cache()->stats().size_bytes_.value(), file_contents.size() * 2); + EXPECT_EQ(cache()->stats().size_count_.value(), 2); + EXPECT_FALSE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-a"))); + EXPECT_FALSE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-b"))); + EXPECT_TRUE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-c"))); + EXPECT_TRUE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-d"))); + // There may have been one or two eviction runs here, because there's a race + // between the eviction and the second file being added. Either amount of runs + // is valid, as the eventual consistency is achieved either way. + EXPECT_THAT(cache()->stats().eviction_runs_.value(), testing::AnyOf(1, 2)); +} + +TEST_F(FileSystemHttpCacheTestWithNoDefaultCache, EvictsOldestFilesUntilUnderSizeLimit) { + const std::string file_contents = "XXXXX"; + const std::string large_file_contents = "XXXXXXXXXXXX"; + const uint64_t max_size = large_file_contents.size(); + ConfigProto cfg = testConfig(); + cfg.mutable_max_cache_size_bytes()->set_value(max_size); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-a"), file_contents, true); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-b"), file_contents, true); + // TODO(#24994): replace this with backdating the files when that's possible. + sleep(1); // NO_CHECK_FORMAT(real_time) + cache_ = *http_cache_factory_->getCache(cacheConfig(cfg), context_); + waitForEvictionThreadIdle(); + EXPECT_EQ(cache()->stats().eviction_runs_.value(), 0); + env_.writeStringToFileForTest(absl::StrCat(cache_path_, "cache-c"), large_file_contents, true); + EXPECT_EQ(cache()->stats().size_bytes_.value(), file_contents.size() * 2); + EXPECT_EQ(cache()->stats().size_count_.value(), 2); + cache()->trackFileAdded(large_file_contents.size()); + waitForEvictionThreadIdle(); + EXPECT_EQ(cache()->stats().size_bytes_.value(), large_file_contents.size()); + EXPECT_EQ(cache()->stats().size_count_.value(), 1); + EXPECT_FALSE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-a"))); + EXPECT_FALSE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-b"))); + EXPECT_TRUE(Filesystem::fileSystemForTest().fileExists(absl::StrCat(cache_path_, "cache-c"))); + EXPECT_EQ(cache()->stats().eviction_runs_.value(), 1); +} + +class FileSystemHttpCacheTest : public FileSystemCacheTestContext, public ::testing::Test { + void SetUp() override { initCache(); } +}; + +MATCHER_P2(IsStatTag, name, value, "") { + if (!ExplainMatchResult(name, arg.name_, result_listener) || + !ExplainMatchResult(value, arg.value_, result_listener)) { + *result_listener << "\nexpected {name: \"" << name << "\", value: \"" << value + << "\"},\n but got {name: \"" << arg.name_ << "\", value: \"" << arg.value_ + << "\"}\n"; + return false; + } + return true; +} + +TEST_F(FileSystemHttpCacheTest, StatsAreConstructedCorrectly) { + std::string cache_path_no_periods = absl::StrReplaceAll(cache_path_, {{".", "_"}}); + // Validate that a gauge has appropriate name and tags. + EXPECT_EQ(cache()->stats().size_bytes_.tagExtractedName(), "cache.size_bytes"); + EXPECT_THAT(cache()->stats().size_bytes_.tags(), + ::testing::ElementsAre(IsStatTag("cache_path", cache_path_no_periods))); + // Validate that a counter has appropriate name and tags. + EXPECT_EQ(cache()->stats().eviction_runs_.tagExtractedName(), "cache.eviction_runs"); + EXPECT_THAT(cache()->stats().eviction_runs_.tags(), + ::testing::ElementsAre(IsStatTag("cache_path", cache_path_no_periods))); +} + +TEST_F(FileSystemHttpCacheTest, TrackFileRemovedClampsAtZero) { + cache()->trackFileAdded(1); + EXPECT_EQ(cache()->stats().size_bytes_.value(), 1); + EXPECT_EQ(cache()->stats().size_count_.value(), 1); + cache()->trackFileRemoved(8); + EXPECT_EQ(cache()->stats().size_bytes_.value(), 0); + EXPECT_EQ(cache()->stats().size_count_.value(), 0); + // Remove a second time to ensure that count going below zero also clamps at zero. + cache()->trackFileRemoved(8); + EXPECT_EQ(cache()->stats().size_bytes_.value(), 0); + EXPECT_EQ(cache()->stats().size_count_.value(), 0); +} + +TEST_F(FileSystemHttpCacheTest, + InvalidArgumentOnTryingToCreateCachesWithDistinctConfigsOnSamePath) { + ConfigProto cfg = testConfig(); + cfg.mutable_manager_config()->mutable_thread_pool()->set_thread_count(2); + EXPECT_THAT(http_cache_factory_->getCache(cacheConfig(cfg), context_), + HasStatusCode(absl::StatusCode::kInvalidArgument)); +} + +TEST_F(FileSystemHttpCacheTest, IdenticalCacheConfigReturnsSameCacheInstance) { + ConfigProto cfg = testConfig(); + auto second_cache = http_cache_factory_->getCache(cacheConfig(cfg), context_); + EXPECT_EQ(cache_, *second_cache); +} + +TEST_F(FileSystemHttpCacheTest, CacheConfigsWithDifferentPathsReturnDistinctCacheInstances) { + ConfigProto cfg = testConfig(); + cfg.set_cache_path("/tmp"); + auto second_cache = http_cache_factory_->getCache(cacheConfig(cfg), context_); + EXPECT_NE(cache_, *second_cache); +} + +class MockSingletonManager : public Singleton::ManagerImpl { +public: + MockSingletonManager() { + // By default just act like a real SingletonManager, but allow overrides. + ON_CALL(*this, get) + .WillByDefault(std::bind(&MockSingletonManager::realGet, this, std::placeholders::_1, + std::placeholders::_2, std::placeholders::_3)); + } + + MOCK_METHOD(Singleton::InstanceSharedPtr, get, + (const std::string& name, Singleton::SingletonFactoryCb cb, bool pin)); + Singleton::InstanceSharedPtr realGet(const std::string& name, Singleton::SingletonFactoryCb cb, + bool pin) { + return Singleton::ManagerImpl::get(name, cb, pin); + } +}; + +class FileSystemHttpCacheTestWithMockFiles : public FileSystemHttpCacheTest { +public: + FileSystemHttpCacheTestWithMockFiles() { + ON_CALL(context_.server_factory_context_, singletonManager()) + .WillByDefault(ReturnRef(mock_singleton_manager_)); + ON_CALL(mock_singleton_manager_, get(HasSubstr("async_file_manager_factory_singleton"), _, _)) + .WillByDefault(Return(mock_async_file_manager_factory_)); + ON_CALL(*mock_async_file_manager_factory_, getAsyncFileManager(_, _)) + .WillByDefault(Return(mock_async_file_manager_)); + request_headers_.setMethod("GET"); + request_headers_.setHost("example.com"); + request_headers_.setScheme("https"); + request_headers_.setCopy(Http::CustomHeaders::get().CacheControl, "max-age=3600"); + request_headers_.setPath("/"); + expect_false_callback_ = [this](bool result) { + EXPECT_FALSE(result); + false_callbacks_called_++; + }; + expect_true_callback_ = [this](bool result) { + EXPECT_TRUE(result); + true_callbacks_called_++; + }; + key_ = CacheHeadersUtils::makeKey(request_headers_, "fake-cluster"); + headers_size_ = headerProtoSize(makeCacheFileHeaderProto(key_, response_headers_, metadata_)); + } + + void setTrailers(Http::TestResponseTrailerMapImpl trailers) { + response_trailers_ = trailers; + trailers_size_ = bufferFromProto(makeCacheFileTrailerProto(response_trailers_)).length(); + } + + void setBodySize(size_t sz) { body_size_ = sz; } + + CacheFileFixedBlock testHeaderBlock() { + CacheFileFixedBlock block; + block.setHeadersSize(headers_size_); + block.setTrailersSize(trailers_size_); + block.setBodySize(body_size_); + return block; + } + + Buffer::InstancePtr testHeaderBlockBuffer() { + auto buffer = std::make_unique(); + testHeaderBlock().serializeToBuffer(*buffer); + return buffer; + } + + CacheFileHeader testHeaderProto() { + return makeCacheFileHeaderProto(key_, response_headers_, metadata_); + } + + Buffer::InstancePtr testHeaderBuffer() { + return std::make_unique(bufferFromProto(testHeaderProto())); + } + + Buffer::InstancePtr undersizedBuffer() { return std::make_unique("x"); } + + CacheFileTrailer testTrailerProto() { return makeCacheFileTrailerProto(response_trailers_); } + + Buffer::InstancePtr testTrailerBuffer() { + return std::make_unique(bufferFromProto(testTrailerProto())); + } + + void SetUp() override { initCache(); } + + void testLookup(absl::StatusOr* lookup_result_out) { + cache()->lookup(LookupRequest{Key{key_}, *dispatcher_}, + [lookup_result_out](absl::StatusOr&& result) { + *lookup_result_out = std::move(result); + }); + pumpDispatcher(); + } + + void testSuccessfulLookup(absl::StatusOr* lookup_result_out) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + if (trailers_size_) { + EXPECT_CALL(*mock_async_file_handle_, + read(_, testHeaderBlock().offsetToTrailers(), trailers_size_, _)); + } + EXPECT_CALL(*mock_async_file_handle_, + read(_, testHeaderBlock().offsetToHeaders(), headers_size_, _)); + testLookup(lookup_result_out); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + if (trailers_size_) { + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testTrailerBuffer())); + pumpDispatcher(); + } + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBuffer())); + pumpDispatcher(); + // result should be populated. + ASSERT_THAT(*lookup_result_out, IsOkAndHolds(PopulatedLookup())); + } + + void pumpDispatcher() { dispatcher_->run(Event::Dispatcher::RunType::Block); } + +protected: + ::testing::NiceMock mock_singleton_manager_; + std::shared_ptr cache_progress_receiver_ = + std::make_shared(); + std::shared_ptr mock_async_file_manager_factory_ = + std::make_shared>(); + std::shared_ptr mock_async_file_manager_ = + std::make_shared>(); + MockAsyncFileHandle mock_async_file_handle_ = + std::make_shared>(mock_async_file_manager_); + NiceMock decoder_callbacks_; + NiceMock encoder_callbacks_; + Event::SimulatedTimeSystem time_system_; + Http::TestRequestHeaderMapImpl request_headers_; + NiceMock factory_context_; + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; + Http::TestResponseHeaderMapImpl response_headers_{ + {":status", "200"}, + {"date", formatter_.fromTime(time_system_.systemTime())}, + {"cache-control", "public,max-age=3600"}, + }; + Http::TestResponseTrailerMapImpl response_trailers_{{"fruit", "banana"}}; + const ResponseMetadata metadata_{time_system_.systemTime()}; + Key key_; + int false_callbacks_called_ = 0; + int true_callbacks_called_ = 0; + std::function expect_false_callback_; + std::function expect_true_callback_; + size_t headers_size_; + size_t trailers_size_{0}; + size_t body_size_{0}; + Api::ApiPtr api_ = Api::createApiForTest(); + Event::DispatcherPtr dispatcher_ = api_->allocateDispatcher("test_thread"); +}; + +TEST_F(FileSystemHttpCacheTestWithMockFiles, NotFoundForReadReturnsMiss) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::NotFoundError("forced not-found"))); + pumpDispatcher(); + EXPECT_FALSE(lookup_result.value().populated()); + // File handle didn't get used but is expected to be closed. + EXPECT_OK(mock_async_file_handle_->close(nullptr, [](absl::Status) {})); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, FailedReadOfHeaderBlockReturnsError) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional failure to read"))); + pumpDispatcher(); + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kUnknown)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, SuccessfulEvictDecreasesStats) { + // Fake-add two files of size 12345, so we can validate the stats decrease of removing a file. + cache()->trackFileAdded(12345); + cache()->trackFileAdded(12345); + EXPECT_EQ(cache()->stats().size_bytes_.value(), 2 * 12345); + EXPECT_EQ(cache()->stats().size_count_.value(), 2); + EXPECT_CALL(*mock_async_file_manager_, stat); + EXPECT_CALL(*mock_async_file_manager_, unlink); + cache()->evict(*dispatcher_, key_); + pumpDispatcher(); + struct stat stat_result = {}; + stat_result.st_size = 12345; + // stat + mock_async_file_manager_->nextActionCompletes(absl::StatusOr{stat_result}); + pumpDispatcher(); + // unlink + mock_async_file_manager_->nextActionCompletes(absl::OkStatus()); + pumpDispatcher(); + // Should have deducted the size of the file that got deleted. Since we started at 2 * 12345, + // this should make the value 12345. + EXPECT_EQ(cache()->stats().size_bytes_.value(), 12345); + // Should have deducted one file for the file that got deleted. Since we started at 2, + // this should make the value 1. + EXPECT_EQ(cache()->stats().size_count_.value(), 1); + // File handle didn't get used but is expected to be closed. + EXPECT_OK(mock_async_file_handle_->close(nullptr, [](absl::Status) {})); +} + +Buffer::InstancePtr invalidHeaderBlock() { + CacheFileFixedBlock block; + auto buffer = std::make_unique(); + block.serializeToBuffer(*buffer); + // Replace the four byte id at the start with a bad id. + buffer->drain(4); + buffer->prepend("BAD!"); + return buffer; +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, ReadWithInvalidHeaderBlockReturnsError) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(invalidHeaderBlock())); + pumpDispatcher(); + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kDataLoss)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, ReadWithIncompleteHeaderBlockReturnsError) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(undersizedBuffer())); + pumpDispatcher(); + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kDataLoss)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, FailedReadOfHeaderProtoReturnsError) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, read(_, CacheFileFixedBlock::size(), headers_size_, _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional failure to read"))); + pumpDispatcher(); + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kUnknown)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, IncompleteReadOfHeaderProtoReturnsError) { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, read(_, CacheFileFixedBlock::size(), headers_size_, _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(undersizedBuffer())); + pumpDispatcher(); + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kDataLoss)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, FailedReadOfBodyProvokesReset) { + setBodySize(10); + absl::StatusOr lookup_result; + testSuccessfulLookup(&lookup_result); + EXPECT_CALL(*mock_async_file_handle_, read(_, testHeaderBlock().offsetToBody(), 8, _)); + Buffer::InstancePtr got_body; + EndStream got_end_stream = EndStream::More; + lookup_result.value().cache_reader_->getBody(*dispatcher_, AdjustedByteRange(0, 8), + [&](Buffer::InstancePtr body, EndStream end_stream) { + got_body = std::move(body); + got_end_stream = end_stream; + }); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional failure to read"))); + pumpDispatcher(); + EXPECT_THAT(got_body, IsNull()); + EXPECT_EQ(got_end_stream, EndStream::Reset); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, IncompleteReadOfBodyProvokesReset) { + setBodySize(10); + absl::StatusOr lookup_result; + testSuccessfulLookup(&lookup_result); + EXPECT_CALL(*mock_async_file_handle_, read(_, testHeaderBlock().offsetToBody(), 8, _)); + Buffer::InstancePtr got_body; + EndStream got_end_stream = EndStream::More; + lookup_result.value().cache_reader_->getBody(*dispatcher_, AdjustedByteRange(0, 8), + [&](Buffer::InstancePtr body, EndStream end_stream) { + got_body = std::move(body); + got_end_stream = end_stream; + }); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(undersizedBuffer())); + pumpDispatcher(); + EXPECT_THAT(got_body, IsNull()); + EXPECT_EQ(got_end_stream, EndStream::Reset); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, FailedReadOfTrailersReturnsError) { + setTrailers({{"fruit", "banana"}}); + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, + read(_, testHeaderBlock().offsetToTrailers(), trailers_size_, _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional failure to read"))); + pumpDispatcher(); + // result should be populated. + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kUnknown)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, IncompleteReadOfTrailersReturnsError) { + setTrailers({{"fruit", "banana"}}); + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, + read(_, testHeaderBlock().offsetToTrailers(), trailers_size_, _)); + absl::StatusOr lookup_result; + testLookup(&lookup_result); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(undersizedBuffer())); + pumpDispatcher(); + // result should be populated. + EXPECT_THAT(lookup_result, HasStatusCode(absl::StatusCode::kDataLoss)); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, InsertAbortsOnFailureToCreateFile) { + EXPECT_CALL(*cache_progress_receiver_, onInsertFailed); + EXPECT_CALL(*mock_async_file_manager_, createAnonymousFile); + cache()->insert(*dispatcher_, key_, + Http::createHeaderMap(response_headers_), metadata_, + nullptr, cache_progress_receiver_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentionally failed to create file"))); + pumpDispatcher(); + // File handle didn't get used but is expected to be closed. + EXPECT_OK(mock_async_file_handle_->close(nullptr, [](absl::Status) {})); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersFailingToReadHeaderBlockAborts) { + EXPECT_LOG_CONTAINS("error", "failed to read header block", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentionally failed"))); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersIncompleteReadHeaderBlockAborts) { + EXPECT_LOG_CONTAINS("error", "incomplete read of header block", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(undersizedBuffer())); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersFailureToTruncateAborts) { + EXPECT_LOG_CONTAINS("error", "failed to truncate headers", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, truncate(_, testHeaderBlock().offsetToHeaders(), _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::UnknownError("intentionally failed")); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersFailureToOverwriteHeaderBlockAborts) { + EXPECT_LOG_CONTAINS("error", "overwriting headers failed", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, truncate(_, testHeaderBlock().offsetToHeaders(), _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::OkStatus()); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentionally failed"))); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersIncompleteOverwriteHeaderBlockAborts) { + EXPECT_LOG_CONTAINS("error", "overwriting headers failed", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, truncate(_, testHeaderBlock().offsetToHeaders(), _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::OkStatus()); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::StatusOr(1)); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersFailureToWriteHeadersAborts) { + EXPECT_LOG_CONTAINS("error", "failed to write new headers", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, truncate(_, testHeaderBlock().offsetToHeaders(), _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, testHeaderBlock().offsetToHeaders(), _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::OkStatus()); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(CacheFileFixedBlock::size())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentionally failed"))); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, UpdateHeadersIncompleteWriteHeadersAborts) { + EXPECT_LOG_CONTAINS("error", "incomplete write of new headers", { + EXPECT_CALL(*mock_async_file_manager_, openExistingFile); + EXPECT_CALL(*mock_async_file_handle_, read(_, 0, CacheFileFixedBlock::size(), _)); + EXPECT_CALL(*mock_async_file_handle_, truncate(_, testHeaderBlock().offsetToHeaders(), _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, testHeaderBlock().offsetToHeaders(), _)); + cache()->updateHeaders(*dispatcher_, key_, response_headers_, metadata_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(testHeaderBlockBuffer())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::OkStatus()); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(CacheFileFixedBlock::size())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::StatusOr(1)); + pumpDispatcher(); + }); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, EvictWithStatFailureSilentlyAborts) { + EXPECT_CALL(*mock_async_file_manager_, stat); + cache()->evict(*dispatcher_, key_); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional failure"))); + pumpDispatcher(); + // File handle didn't get used but is expected to be closed. + EXPECT_OK(mock_async_file_handle_->close(nullptr, [](absl::Status) {})); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, EvictWithUnlinkFailureSilentlyAborts) { + EXPECT_CALL(*mock_async_file_manager_, stat); + EXPECT_CALL(*mock_async_file_manager_, unlink); + cache()->evict(*dispatcher_, key_); + pumpDispatcher(); + struct stat stat_result = {}; + stat_result.st_size = 12345; + mock_async_file_manager_->nextActionCompletes(absl::StatusOr(stat_result)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::UnknownError("intentional failure")); + pumpDispatcher(); + // File handle didn't get used but is expected to be closed. + EXPECT_OK(mock_async_file_handle_->close(nullptr, [](absl::Status) {})); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, InsertAbortsOnFailureToDupFileHandle) { + EXPECT_CALL(*cache_progress_receiver_, onInsertFailed); + EXPECT_CALL(*mock_async_file_manager_, createAnonymousFile); + EXPECT_CALL(*mock_async_file_handle_, duplicate); + cache()->insert(*dispatcher_, key_, + Http::createHeaderMap(response_headers_), metadata_, + nullptr, cache_progress_receiver_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentionally failed to dup file"))); + pumpDispatcher(); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, InsertAbortsOnFailureToWriteEmptyHeaderBlock) { + auto duplicated_file_handle = std::make_shared(); + EXPECT_CALL(*duplicated_file_handle, close).WillOnce([]() { return []() {}; }); + auto http_source = std::make_unique(); + EXPECT_CALL(*cache_progress_receiver_, onHeadersInserted); + EXPECT_CALL(*cache_progress_receiver_, onInsertFailed); + EXPECT_CALL(*mock_async_file_manager_, createAnonymousFile); + EXPECT_CALL(*mock_async_file_handle_, duplicate); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + cache()->insert(*dispatcher_, key_, + Http::createHeaderMap(response_headers_), metadata_, + std::move(http_source), cache_progress_receiver_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(duplicated_file_handle)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::StatusOr( + absl::UnknownError("intentionally failed write to empty header block"))); + pumpDispatcher(); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, InsertAbortsOnFailureToWriteBodyChunk) { + auto duplicated_file_handle = std::make_shared(); + EXPECT_CALL(*duplicated_file_handle, close).WillOnce([]() { return []() {}; }); + auto http_source = + std::make_unique(*dispatcher_, nullptr, "abcde", nullptr); + EXPECT_CALL(*cache_progress_receiver_, onHeadersInserted); + EXPECT_CALL(*cache_progress_receiver_, onInsertFailed); + EXPECT_CALL(*mock_async_file_manager_, createAnonymousFile); + EXPECT_CALL(*mock_async_file_handle_, duplicate); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, testHeaderBlock().offsetToBody(), _)); + cache()->insert(*dispatcher_, key_, + Http::createHeaderMap(response_headers_), metadata_, + std::move(http_source), cache_progress_receiver_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(duplicated_file_handle)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::StatusOr(testHeaderBlock().size())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional fail to write body"))); + pumpDispatcher(); +} + +TEST_F(FileSystemHttpCacheTestWithMockFiles, InsertSilentlyAbortsOnFailureToWriteTrailerChunk) { + setTrailers({{"fruit", "banana"}}); + auto duplicated_file_handle = std::make_shared(); + EXPECT_CALL(*duplicated_file_handle, close).WillOnce([]() { return []() {}; }); + auto http_source = std::make_unique( + *dispatcher_, nullptr, "", + Http::createHeaderMap(response_trailers_)); + EXPECT_CALL(*cache_progress_receiver_, onHeadersInserted); + EXPECT_CALL(*cache_progress_receiver_, onTrailersInserted); + EXPECT_CALL(*mock_async_file_manager_, createAnonymousFile); + EXPECT_CALL(*mock_async_file_handle_, duplicate); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, 0, _)); + EXPECT_CALL(*mock_async_file_handle_, write(_, _, testHeaderBlock().offsetToTrailers(), _)); + cache()->insert(*dispatcher_, key_, + Http::createHeaderMap(response_headers_), metadata_, + std::move(http_source), cache_progress_receiver_); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(mock_async_file_handle_)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(duplicated_file_handle)); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes(absl::StatusOr(testHeaderBlock().size())); + pumpDispatcher(); + mock_async_file_manager_->nextActionCompletes( + absl::StatusOr(absl::UnknownError("intentional fail to write body"))); + pumpDispatcher(); +} + +// For the standard cache tests from http_cache_implementation_test_common.cc +// These will be run with the real file system, and therefore only cover the +// "no file errors" paths. +class FileSystemHttpCacheTestDelegate : public HttpCacheTestDelegate, + public FileSystemCacheTestContext { +public: + FileSystemHttpCacheTestDelegate() { initCache(); } + HttpCache& cache() override { return cache_->cache(); } + void beforePumpingDispatcher() override { + dynamic_cast(cache()).drainAsyncFileActionsForTest(); + } +}; + +// For the standard cache tests from http_cache_implementation_test_common.cc +INSTANTIATE_TEST_SUITE_P(FileSystemHttpCacheTest, HttpCacheImplementationTest, + testing::Values(std::make_unique), + [](const testing::TestParamInfo&) { + return "FileSystemHttpCache"; + }); + +TEST(Registration, GetCacheFromFactory) { + HttpCacheFactory* factory = Registry::FactoryRegistry::getFactoryByType( + "envoy.extensions.http.cache_v2.file_system_http_cache.v3.FileSystemHttpCacheV2Config"); + ASSERT_NE(factory, nullptr); + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config cache_config; + NiceMock factory_context; + ON_CALL(factory_context.server_factory_context_.api_, threadFactory()) + .WillByDefault([]() -> Thread::ThreadFactory& { return Thread::threadFactoryForTest(); }); + TestUtility::loadFromYaml(std::string(yaml_config), cache_config); + auto status_or_cache = factory->getCache(cache_config, factory_context); + ASSERT_OK(status_or_cache); + EXPECT_EQ((*status_or_cache)->cacheInfo().name_, + "envoy.extensions.http.cache_v2.file_system_http_cache"); + // Verify that the config path got a / suffixed onto it. + EXPECT_EQ(dynamic_cast((*status_or_cache)->cache()).config().cache_path(), + "/tmp/"); +} + +} // namespace FileSystemHttpCache +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/http/cache_v2/simple_http_cache/BUILD b/test/extensions/http/cache_v2/simple_http_cache/BUILD new file mode 100644 index 0000000000000..ad7a737e42339 --- /dev/null +++ b/test/extensions/http/cache_v2/simple_http_cache/BUILD @@ -0,0 +1,25 @@ +load("//bazel:envoy_build_system.bzl", "envoy_package") +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "simple_http_cache_test", + srcs = ["simple_http_cache_test.cc"], + extension_names = ["envoy.extensions.http.cache_v2.simple"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/http/cache_v2:cache_entry_utils_lib", + "//source/extensions/http/cache_v2/simple_http_cache:config", + "//test/extensions/filters/http/cache_v2:http_cache_implementation_test_common_lib", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:status_utility_lib", + "//test/test_common:utility_lib", + ], +) diff --git a/test/extensions/http/cache_v2/simple_http_cache/simple_http_cache_test.cc b/test/extensions/http/cache_v2/simple_http_cache/simple_http_cache_test.cc new file mode 100644 index 0000000000000..d05da2344b4a7 --- /dev/null +++ b/test/extensions/http/cache_v2/simple_http_cache/simple_http_cache_test.cc @@ -0,0 +1,54 @@ +#include "envoy/http/header_map.h" +#include "envoy/registry/registry.h" + +#include "source/common/buffer/buffer_impl.h" +#include "source/extensions/filters/http/cache_v2/cache_entry_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_headers_utils.h" +#include "source/extensions/filters/http/cache_v2/cache_sessions.h" +#include "source/extensions/http/cache_v2/simple_http_cache/simple_http_cache.h" + +#include "test/extensions/filters/http/cache_v2/http_cache_implementation_test_common.h" +#include "test/mocks/server/factory_context.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/status_utility.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace CacheV2 { +namespace { + +class SimpleHttpCacheTestDelegate : public HttpCacheTestDelegate { +public: + HttpCache& cache() override { return cache_; } + +private: + SimpleHttpCache cache_; +}; + +INSTANTIATE_TEST_SUITE_P(SimpleHttpCacheTest, HttpCacheImplementationTest, + testing::Values(std::make_unique), + [](const testing::TestParamInfo&) { + return "SimpleHttpCache"; + }); + +TEST(Registration, GetFactory) { + HttpCacheFactory* factory = Registry::FactoryRegistry::getFactoryByType( + "envoy.extensions.http.cache_v2.simple_http_cache.v3.SimpleHttpCacheV2Config"); + ASSERT_NE(factory, nullptr); + envoy::extensions::filters::http::cache_v2::v3::CacheV2Config config; + testing::NiceMock factory_context; + config.mutable_typed_config()->PackFrom(*factory->createEmptyConfigProto()); + auto cache = factory->getCache(config, factory_context); + ASSERT_OK(cache); + EXPECT_EQ((*cache)->cacheInfo().name_, "envoy.extensions.http.cache_v2.simple"); +} + +} // namespace +} // namespace CacheV2 +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/tools/code_format/config.yaml b/tools/code_format/config.yaml index bd4915bb4ad60..57a71f3421757 100644 --- a/tools/code_format/config.yaml +++ b/tools/code_format/config.yaml @@ -315,6 +315,7 @@ paths: - source/common/protobuf/utility.cc - source/extensions/filters/http/grpc_json_transcoder/json_transcoder_filter.cc - source/extensions/http/cache/file_system_http_cache/cache_file_header_proto_util.cc + - source/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util.cc - test/common/grpc/codec_fuzz_test.cc - test/common/grpc/codec_test.cc - test/common/protobuf/utility_test.cc @@ -323,6 +324,7 @@ paths: - test/extensions/filters/common/expr/context_test.cc - test/extensions/filters/http/common/fuzz/uber_filter.h - test/extensions/http/cache/file_system_http_cache/cache_file_header_proto_util_test.cc + - test/extensions/http/cache_v2/file_system_http_cache/cache_file_header_proto_util_test.cc - test/tools/router_check/router_check.cc - source/extensions/bootstrap/reverse_tunnel/downstream_socket_interface/rc_connection_wrapper.cc - test/extensions/bootstrap/reverse_tunnel/downstream_socket_interface/rc_connection_wrapper_test.cc diff --git a/tools/extensions/extensions_schema.yaml b/tools/extensions/extensions_schema.yaml index c96efd33cfd89..90ea79fac1416 100644 --- a/tools/extensions/extensions_schema.yaml +++ b/tools/extensions/extensions_schema.yaml @@ -83,6 +83,7 @@ categories: - envoy.health_checkers - envoy.health_check.event_sinks - envoy.http.cache +- envoy.http.cache_v2 - envoy.http.header_validators - envoy.http.stateful_header_formatters - envoy.internal_redirect_predicates