Skip to content

ext_authz cache #37953

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package envoy.extensions.filters.http.ext_authz.v3;
import "envoy/config/common/mutation_rules/v3/mutation_rules.proto";
import "envoy/config/core/v3/base.proto";
import "envoy/config/core/v3/config_source.proto";
import "envoy/config/core/v3/extension.proto";
import "envoy/config/core/v3/grpc_service.proto";
import "envoy/config/core/v3/http_uri.proto";
import "envoy/type/matcher/v3/metadata.proto";
Expand All @@ -30,7 +31,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE;
// External Authorization :ref:`configuration overview <config_http_filters_ext_authz>`.
// [#extension: envoy.filters.http.ext_authz]

// [#next-free-field: 30]
// [#next-free-field: 32]
message ExtAuthz {
option (udpa.annotations.versioning).previous_message_type =
"envoy.config.filter.http.ext_authz.v3.ExtAuthz";
Expand Down Expand Up @@ -310,6 +311,11 @@ message ExtAuthz {
// Field ``latency_us`` is exposed for CEL and logging when using gRPC or HTTP service.
// Fields ``bytesSent`` and ``bytesReceived`` are exposed for CEL and logging only when using gRPC service.
bool emit_filter_state_stats = 29;

// Cache configuration to store response from external auth server.
optional envoy.config.core.v3.TypedExtensionConfig response_cache_config = 30;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
optional envoy.config.core.v3.TypedExtensionConfig response_cache_config = 30;
envoy.config.core.v3.TypedExtensionConfig response_cache_config = 30;

optional is the default in proto3

Copy link
Author

Choose a reason for hiding this comment

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

Good point, thank you. I'll fix it in the next commit.

// Tell cache whether to remember the whole response from authz server (response body, header modification, meta data, status) or just status.
optional bool response_cache_remember_body_headers = 31;
Copy link
Member

Choose a reason for hiding this comment

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

This should be in the extension's configuration; it's likely that some cache types wouldn't support this option.

Copy link
Author

Choose a reason for hiding this comment

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

We had put in a lot of thoughts into this choice. Let me explain the pros & cons.

  • Option 1: "remember_body_headers" config is in SimpleCache:
    Pro: All cache related configurations are in one place (inside response_cache_config).
    Con: This would make SimpleCache specific to ext_authz. The full response is defined by Filters::Common::ExtAuthz::Response, which is ext_authz data structure. If SimpleCache uses this, then it can only used for ext_authz. Earlier, we received a feedback to try to make cache independent of the use case, so that it can be used from another use case (e.g. ext_proc).

  • Option 2: "remember_body_headers" config is in ExtAuthz:
    Pro: Keeps RespCache and SimpleCache agnostic to the type of stored data. We think this is generally a good design - makes cache more flexible.
    Con: ExtAuthz has two cache related configurations, instead of one.

The way we think is the choice of caching the full response, or just status code, is a functionality of ExtAuthz. ExtAuthz does different things depending on this configuration.

I do agree with an earlier feedback that suggested to decouple the cache from ExtAuthz, so that it can be used in other contexts.

Copy link
Member

Choose a reason for hiding this comment

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

You'll need another layer that is the implementation behind the typed_config. It will have this boolean setting. It will instantiate a cache (however that is done) and return a pointer to an interface that is implementation-agnostic.

This setting definitely belongs in the typed_config, not here.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree that if we have this field, it belongs in the cache config extension, not directly here in the ext_authz filter config.

That having been said, I don't think we should actually have this field at all. See my comment elsewhere about performance considerations: I don't think we want to have to construct a CheckRequest proto or decode a CheckResponse proto in the cache-hit case. Given that, I don't think we want to be caching the CheckResponse proto or its associated headers in the first place.

}

// Configuration for buffering the request data.
Expand Down
32 changes: 32 additions & 0 deletions source/common/resp_cache/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
licenses(["notice"]) # Apache 2

proto_library(
name = "simple_cache_proto",
srcs = ["simple_cache.proto"],
deps = [
"@com_google_protobuf//:descriptor_proto",
],
)

cc_proto_library(
name = "simple_cache_cc_proto",
deps = [":simple_cache_proto"],
)

cc_library(
name = "resp_cache",
srcs = [
# Add any .cc files if you have implementations in .cc files
],
hdrs = [
"resp_cache.h",
"resp_cache_factory.h",
"simple_cache.h",
],
visibility = ["//visibility:public"],
deps = [
":simple_cache_cc_proto",
"@com_google_absl//absl/container:flat_hash_map",
"@com_google_absl//absl/types:optional",
],
)
39 changes: 39 additions & 0 deletions source/common/resp_cache/resp_cache.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#pragma once
Copy link
Member

Choose a reason for hiding this comment

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

This only has a single use right now, in ext_authz. Move this file into that extension.

Copy link
Author

Choose a reason for hiding this comment

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

We had the cache code inside ext_authz initially.

We moved it to the common location in response to this comment by @adisuissa :

A drive-by suggestion: it may be beneficial to generalize the caching for both ext_authz and ext_proc.
I suggest creating the cache configuration (an implementation) separate, and use it only for ext_authz in the first step.

I think this feedback makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we know enough yet to know whether we'll be able to use the same cache structure for ext_authz and ext_proc. I think that at the xDS layer, we should have a separate API for each one. If it turns out that the underlying implementations can actually share code, they can do so easily enough.


// Interface class for Response Cache

#pragma once

#include <string>

//#include "simple_cache.h"

namespace Envoy {
namespace Common {
namespace RespCache {

template <typename T> class RespCache {
public:
virtual ~RespCache() = default;

// Method to insert a value into the cache with a TTL
// ttl_seconds -1 means use the default TTL
virtual bool Insert(const Envoy::Http::RequestHeaderMap& headers, const T& value,
int ttl_seconds = -1) = 0;

// Method to get a cached response based on the cache key
virtual absl::optional<T> Get(const Envoy::Http::RequestHeaderMap& headers) = 0;

// Method to erase a value from the cache
virtual bool Erase(const Envoy::Http::RequestHeaderMap& headers) = 0;

// Public getter method for max cache size (number of objects that can be in cache)
virtual std::size_t getMaxCacheSize() const = 0;

// Publig getter method for current cache size (number of objects in cache)
virtual size_t Size() const = 0;
};

} // namespace RespCache
} // namespace Common
} // namespace Envoy
40 changes: 40 additions & 0 deletions source/common/resp_cache/resp_cache_factory.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#pragma once

// This factory creates a ResponseCache object based on the configuration.

#pragma once

#include <memory>

#include "source/common/protobuf/utility.h"

#include "absl/status/statusor.h"
#include "resp_cache.h"
#include "simple_cache.h"

namespace Envoy {
namespace Common {
namespace RespCache {

enum class CacheType { Simple };

template <typename T>
absl::StatusOr<std::unique_ptr<RespCache<T>>>
createCache(CacheType type, const envoy::config::core::v3::TypedExtensionConfig& config,
Envoy::TimeSource& time_source) {
if (type == CacheType::Simple) {
envoy::common::resp_cache::v3::SimpleCacheConfig simple_cache_config;
if (!Envoy::MessageUtil::unpackTo(config.typed_config(), simple_cache_config).ok()) {
return absl::InvalidArgumentError("Invalid config type for SimpleCache");
}
auto config_ptr =
std::make_shared<envoy::common::resp_cache::v3::SimpleCacheConfig>(simple_cache_config);
return std::make_unique<SimpleCache<T>>(config_ptr, time_source);
} else {
return absl::InvalidArgumentError("Unsupported cache type");
}
}

} // namespace RespCache
} // namespace Common
} // namespace Envoy
243 changes: 243 additions & 0 deletions source/common/resp_cache/simple_cache.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#pragma once

// The file contains SimpleCache class, which implements RespCache interface.

#pragma once

#include <algorithm> // For std::shuffle
#include <random> // For std::default_random_engine
#include <vector>

#include "envoy/common/time.h"

#include "source/common/common/logger.h"
#include "source/common/common/thread.h"
#include "source/common/resp_cache/simple_cache.pb.h" // For configuration in simple_cache.proto

#include "absl/container/flat_hash_map.h"
#include "absl/types/optional.h"
#include "resp_cache.h" // For interface RespCache, which is implemented by SimpleCache.

namespace Envoy {
namespace Common {
namespace RespCache {

/**
A simple cache class with TTL.
It has a random subset eviction policy. This is memory efficient because it does not need to store
the order of elements. It restricts stored values to 16-bit unsigned integers, making it
memory efficient.
*/
template <typename T>
class SimpleCache : public RespCache<T>, public Logger::Loggable<Logger::Id::filter> {
public:
SimpleCache(std::shared_ptr<envoy::common::resp_cache::v3::SimpleCacheConfig> config,
Envoy::TimeSource& time_source)
: config_(std::move(config)), random_generator_(std::random_device{}()),
time_source_(time_source) {}

~SimpleCache() = default;

// Public getter method for max_cache_size
std::size_t getMaxCacheSize() const override { return config_->max_cache_size(); }

bool Insert(const Http::RequestHeaderMap& headers, const T& value,
int ttl_seconds = -1) override {
std::string key = makeCacheKey(headers);
if (key.empty()) {
return false;
}
auto expiration_time = CalculateExpirationTime(ttl_seconds);
CacheItem item(value, expiration_time);
return InsertInternal(key, std::move(item));
}

bool Erase(const Http::RequestHeaderMap& headers) override {
std::string key = makeCacheKey(headers);
if (key.empty()) {
return false;
}
Thread::LockGuard lock{mutex_};
auto it = cache_items_map.find(key);
if (it != cache_items_map.end()) {
cache_items_map.erase(it);
return true;
}
return false;
}

absl::optional<T> Get(const Http::RequestHeaderMap& headers) override {
std::string key = makeCacheKey(headers);
if (key.empty()) {
return absl::nullopt;
}
Thread::LockGuard lock{mutex_};
auto it = cache_items_map.find(key);
if (it != cache_items_map.end()) {
if (time_source_.monotonicTime() < it->second.expiration_time) {
ENVOY_LOG(debug, "Cache hit: key {}", key);
return it->second.value;
} else {
// Item has expired
ENVOY_LOG(debug, "Cache miss: key {} has expired", key);
cache_items_map.erase(it);
}
}
ENVOY_LOG(debug, "Cache miss: key {} not found", key);
return absl::nullopt;
}

size_t Size() const override {
Thread::LockGuard lock{mutex_};
return cache_items_map.size();
}

private:
// struct to define the cache item stored in the cache.
// It takes a template so that it can be only status code (to minimize memory footprint),
// or it can be the full response (to take advantage of full functionality of authz server).
struct CacheItem {
T value;
std::chrono::steady_clock::time_point expiration_time;

CacheItem() = default; // default-constructed

CacheItem(const T& val, std::chrono::steady_clock::time_point exp_time)
: value(val), expiration_time(exp_time) {}
};

// Make cache key from request header
std::string makeCacheKey(const Http::RequestHeaderMap& headers) const {
const auto header_entry = headers.get(Http::LowerCaseString(config_->cache_key_header()));
if (!header_entry.empty()) {
return std::string(header_entry[0]->value().getStringView());
}
return "";
}

// Calculate expiration time with 5% jitter
std::chrono::steady_clock::time_point CalculateExpirationTime(int ttl_seconds) {
if (ttl_seconds == -1) {
ttl_seconds = config_->ttl_seconds();
}

std::uniform_real_distribution<double> distribution(-0.05, 0.05);
double jitter_factor = distribution(random_generator_);
auto jittered_ttl = std::chrono::seconds(ttl_seconds) +
std::chrono::seconds(static_cast<int>(ttl_seconds * jitter_factor));

return time_source_.monotonicTime() + jittered_ttl;
}

bool InsertInternal(const std::string& key, CacheItem&& item) {
Thread::LockGuard lock{mutex_};
auto it = cache_items_map.find(key);
if (it == cache_items_map.end()) {
if (cache_items_map.size() >= config_->max_cache_size()) {
Evict();
}
cache_items_map[key] = std::move(item);
} else {
cache_items_map[key] = std::move(item);
}
ENVOY_LOG(debug, "Inserted cache key {}", key);
return true;
}

// Eviction algorithm emulates LRU by:
// 1. Advance iterator on the cache randomly
// 2. Read 1000 items to decide average TTL
// 3. Look at some (by default, 1% of the whole cache) items, and delete items whose TTL are older
// than a fraction (by default, 50%) of the average. Eviction takes two configuration parameters:
// - Eviction candidate ratio: The fraction of the cache to read for potential eviction
// - Eviction threshold ratio: Evict items with TTL lower than X% of the average TTL
void Evict() {
// TODO: convert duration logging to log
// Start measuring real time
// auto start_real_time = std::chrono::high_resolution_clock::now();
// Start measuring CPU time
// std::clock_t start_cpu_time = std::clock();

// Step 1: Advance pointer randomly
std::uniform_int_distribution<std::size_t> distribution(
0, (1.0 - config_->eviction_candidate_ratio()) * config_->max_cache_size());
std::size_t advance_steps = distribution(random_generator_);

auto it = cache_items_map.begin();
for (std::size_t i = 0; i < advance_steps && it != cache_items_map.end(); ++i) {
++it;
}
auto iterator_save = it;

// Step 2: Read the next min(1000, num_remove_candidates) items and calculate the sum of TTLs
auto num_remove_candidates =
static_cast<std::size_t>(config_->eviction_candidate_ratio() * config_->max_cache_size());
auto current_time = time_source_.monotonicTime();
std::size_t items_to_read = std::min(static_cast<std::size_t>(1000), num_remove_candidates);
double sum_ttl = 0.0;
double min_ttl = std::numeric_limits<double>::max();
for (std::size_t i = 0; i < items_to_read && it != cache_items_map.end(); ++i, ++it) {
auto item_ttl = std::chrono::duration_cast<std::chrono::milliseconds>(
it->second.expiration_time - current_time)
.count();
sum_ttl += item_ttl;
min_ttl = std::min(min_ttl, static_cast<double>(item_ttl));
}
double average_ttl = sum_ttl / items_to_read;

// Step 3: Evict items with TTL lower than a fraction of the average TTL
std::size_t removed = 0;
// Evict items with TTL lower than X% of the average TTL
auto eviction_threshold = std::chrono::milliseconds(
static_cast<int>(min_ttl + (average_ttl - min_ttl) * config_->eviction_threshold_ratio()));

// TODO: convert to log - std::cout << "Average TTL: " << average_ttl << " ms, Min TTL: " <<
// min_ttl << " ms, Eviction threshold: " << eviction_threshold.count() << " ms" << std::endl;

it = iterator_save;
for (std::size_t i = 0; i < num_remove_candidates && it != cache_items_map.end();) {
auto item_ttl = it->second.expiration_time - current_time;
if (item_ttl.count() < 0 || item_ttl < eviction_threshold) {
auto it_next = std::next(it);
cache_items_map.erase(it);
it = it_next;
removed++;
} else {
++it;
++i;
}
}

// Stop measuring real time
// auto end_real_time = std::chrono::high_resolution_clock::now();
// Stop measuring CPU time
// std::clock_t end_cpu_time = std::clock();

// Calculate elapsed real time in microseconds
// auto elapsed_real_time = std::chrono::duration_cast<std::chrono::microseconds>(end_real_time
// - start_real_time);
// Calculate elapsed CPU time
// double elapsed_cpu_time = double(end_cpu_time - start_cpu_time) * 1000000 / CLOCKS_PER_SEC;

// Output the results
// std::cout << "Real time: " << elapsed_real_time.count() << " microseconds\n";
// std::cout << "CPU time: " << elapsed_cpu_time << " microseconds\n";
}

absl::flat_hash_map<std::string, CacheItem> cache_items_map;

mutable Thread::MutexBasicLockable
mutex_; // Mark mutex_ as mutable to allow locking in const methods
// Note that this is a single mutex for the entire cache. This makes all Worker threads to
// synchronize on this mutex. This is not a problem. We implemented a cache which breaks up the
// mutex and flat_hash_map into 100 buckets. We then tested with 1,000 threads making requests at
// the same time. The result - the number of buckets (1 or 100) did not make any difference.

std::shared_ptr<envoy::common::resp_cache::v3::SimpleCacheConfig> config_;
std::default_random_engine random_generator_; // Random number generator
Envoy::TimeSource& time_source_; // Reference to TimeSource
};

} // namespace RespCache
} // namespace Common
} // namespace Envoy
Loading