Copilot commented on code in PR #13817:
URL: https://github.com/apache/trafficserver/pull/13817#discussion_r4221745328
##########
plugins/experimental/abuse_shield/CMakeLists.txt:
##########
@@ -44,4 +44,13 @@ if(BUILD_TESTING)
test_abuse_shield_config PRIVATE Catch2::Catch2WithMain libswoc::libswoc
ts::tsutil yaml-cpp::yaml-cpp
)
add_catch2_test(NAME test_abuse_shield_config COMMAND
test_abuse_shield_config)
+
+ # Micro-benchmarks for the hook path, the maintenance pass, and eviction.
+ # Built but not registered with ctest, so they add nothing to CI time. Catch2
+ # is only available when BUILD_TESTING is on, hence the nesting.
+ if(ENABLE_BENCHMARKS)
+ add_executable(benchmark_abuse_shield unit_tests/benchmark_abuse_shield.cc
ip_data.cc)
+ target_include_directories(benchmark_abuse_shield PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}")
+ target_link_libraries(benchmark_abuse_shield PRIVATE
Catch2::Catch2WithMain libswoc::libswoc)
+ endif()
Review Comment:
The PR description says the benchmark 'builds only with
-DENABLE_BENCHMARKS=ON', but the target is also gated by the outer
`if(BUILD_TESTING)`. Either update the description to mention
`BUILD_TESTING=ON` is also required, or adjust the build logic so benchmarks
can be built when `ENABLE_BENCHMARKS` is enabled (while still ensuring Catch2
is available).
##########
plugins/experimental/abuse_shield/unit_tests/benchmark_abuse_shield.cc:
##########
@@ -0,0 +1,671 @@
+/** @file
+
+ Micro-benchmarks for abuse_shield IP tracking: the per-request hook path,
+ the 1 Hz maintenance pass, and eviction from a full table.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
contributor license
+ agreements. See the NOTICE file distributed with this work for additional
information regarding
+ copyright ownership. The ASF licenses this file to you under the Apache
License, Version 2.0
+ (the "License"); you may not use this file except in compliance with the
License. You may obtain
+ a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
distributed under the License
+ is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express
+ or implied. See the License for the specific language governing permissions
and limitations under
+ the License.
+*/
+
+// abuse_shield.cc calls the TS API throughout, so it cannot be linked here.
+// The functions below that carry a "Mirrors" comment copy the loop of the
+// named plugin function with the TS API calls removed. Keep them in step with
+// abuse_shield.cc. Everything they call (UdiTable, RuleBuckets, TokenBucket)
is
+// the real code from ip_data.cc and tsutil/UdiTable.h.
+
+#include "config.h"
+#include "ip_data.h"
+
+#define CATCH_CONFIG_ENABLE_BENCHMARKING
+#include <catch2/catch_test_macros.hpp>
+#include <catch2/benchmark/catch_benchmark.hpp>
+#include <catch2/generators/catch_generators.hpp>
+
+#include <algorithm>
+#include <atomic>
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <random>
+#include <string>
+#include <thread>
+#include <type_traits>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+
+using namespace abuse_shield;
+
+namespace
+{
+// Every tracker entry carries this many rule buckets, one per configured rule
+// that sets a rate for that tracker's metric.
+constexpr int RULES_PER_METRIC = 3;
+
+// One address in this many is IPv6, the rest IPv4.
+constexpr size_t IP6_EVERY = 10;
+
+// Table sizes. Production runs with 100k slots.
+#define BENCH_TABLE_SIZES GENERATE(as<size_t>{}, 10'000, 50'000, 100'000)
+
+// Stands in for TSStatIntSet in sync_tracker_stats.
+std::atomic<int64_t> g_stat_sink{0};
+
+/** The parts of abuse_shield::Config that the hook and maintenance paths read.
+ *
+ * The real Config is built by parsing YAML, which pulls in yaml-cpp and
+ * tsutil. The address spaces are empty, as in a configuration without trusted
+ * or rate-limited IP lists, but they are still searched so the per-request
+ * lookups are paid for.
+ */
+struct BenchConfig {
+ BenchConfig() = default;
+ explicit BenchConfig(std::vector<Rule> rules_in) :
rules(std::move(rules_in)) {}
+
+ std::vector<Rule> rules;
+ swoc::IPSpace<bool> trusted_ips;
+ swoc::IPSpace<bool> rate_limited_req_ips;
+ swoc::IPSpace<bool> rate_limited_conn_ips;
+ swoc::IPSpace<bool> rate_limited_h2_ips;
+
+ // Mirrors Config::is_trusted.
+ bool
+ is_trusted(const swoc::IPAddr &ip) const
+ {
+ return trusted_ips.find(ip) != trusted_ips.end();
+ }
+
+ // Mirrors Config::is_rate_limited_for_metric.
+ bool
+ is_rate_limited_for_metric(const swoc::IPAddr &ip, RateMetric metric) const
+ {
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return rate_limited_req_ips.find(ip) != rate_limited_req_ips.end();
+ case RateMetric::CONNECTION:
+ return rate_limited_conn_ips.find(ip) != rate_limited_conn_ips.end();
+ case RateMetric::H2_ERROR:
+ return rate_limited_h2_ips.find(ip) != rate_limited_h2_ips.end();
+ }
+ return false;
+ }
+
+ // Mirrors Config::rule_applies_to_ip.
+ bool
+ rule_applies_to_ip(const Rule &rule, const swoc::IPAddr &ip) const
+ {
+ if (rule.filter.has_rate_limited_ips()) {
+ return rule.filter.rate_limited_ips->find(ip) !=
rule.filter.rate_limited_ips->end();
+ }
+ if (rule.filter.max_req_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::REQUEST)) {
+ return false;
+ }
+ if (rule.filter.max_conn_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::CONNECTION)) {
+ return false;
+ }
+ if (rule.filter.max_h2_error_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::H2_ERROR)) {
+ return false;
+ }
+ return true;
+ }
+};
+
+/** Build a rule set like the shipped abuse_shield.yaml: each rule limits one
+ * metric. With @a rules_per_metric rules per metric, every tracker entry holds
+ * that many buckets once it has seen traffic.
+ */
+std::vector<Rule>
+make_rules(int rules_per_metric)
+{
+ std::vector<Rule> rules;
+ for (int i = 0; i < rules_per_metric; ++i) {
+ Rule req;
+ req.name = "excessive_requests_" +
std::to_string(i);
+ req.filter.max_req_rate = 100 * (i + 1);
+ req.filter.req_burst_multiplier = 2.0;
+ rules.push_back(req);
+
+ Rule conn;
+ conn.name = "excessive_connections_" +
std::to_string(i);
+ conn.filter.max_conn_rate = 20 * (i + 1);
+ conn.filter.conn_burst_multiplier = 1.5;
+ rules.push_back(conn);
+
+ Rule h2;
+ h2.name = "excessive_h2_errors_" + std::to_string(i);
+ h2.filter.max_h2_error_rate = 10 * (i + 1);
+ h2.filter.h2_burst_multiplier = 1.0;
+ rules.push_back(h2);
+ }
+ return rules;
+}
+
+// Mirrors metric_rate in abuse_shield.cc.
+int
+metric_rate(const RuleFilter &filter, RateMetric metric)
+{
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return filter.max_req_rate;
+ case RateMetric::CONNECTION:
+ return filter.max_conn_rate;
+ case RateMetric::H2_ERROR:
+ return filter.max_h2_error_rate;
+ }
+ return 0;
+}
+
+// Mirrors metric_burst_multiplier in abuse_shield.cc.
+double
+metric_burst_multiplier(const RuleFilter &filter, RateMetric metric)
+{
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return filter.req_burst_multiplier;
+ case RateMetric::CONNECTION:
+ return filter.conn_burst_multiplier;
+ case RateMetric::H2_ERROR:
+ return filter.h2_burst_multiplier;
+ }
+ return 1.0;
+}
+
+// Mirrors the TrackerStats counters that consume_rule_buckets increments.
+struct BenchStats {
+ std::atomic<int64_t> events{0};
+ std::atomic<int64_t> events_untracked{0};
+ std::atomic<int64_t> scan_exhausted{0};
+};
+
+// Mirrors consume_rule_buckets in abuse_shield.cc. TSStatIntIncrement becomes
+// a relaxed atomic increment.
+template <typename Table>
+void
+consume_rule_buckets(Table *tracker, const swoc::IPAddr &ip, const BenchConfig
&config, RateMetric metric, BenchStats &stats,
+ uint64_t error_code = 0)
+{
+ typename Table::data_ptr slot;
+ bool consumed = false;
+
+ for (const auto &rule : config.rules) {
+ int rate = metric_rate(rule.filter, metric);
+ if (rate <= 0 || !config.rule_applies_to_ip(rule, ip)) {
+ continue;
+ }
+
+ if (!slot) {
+ typename Table::ProcessStatus status;
+ slot = tracker->process_event(ip, 1, &status);
+ if (!slot) {
+ stats.events_untracked.fetch_add(1, std::memory_order_relaxed);
+ if (status == Table::ProcessStatus::NO_CANDIDATE) {
+ stats.scan_exhausted.fetch_add(1, std::memory_order_relaxed);
+ }
+ return;
+ }
+ }
+
+ int burst = static_cast<int>(static_cast<double>(rate) *
metric_burst_multiplier(rule.filter, metric));
+ if constexpr (std::is_same_v<typename Table::data_type, H2Data>) {
+ slot->consume(rule.name, rate, burst, error_code);
+ } else {
+ slot->consume(rule.name, rate, burst);
+ }
+ consumed = true;
+ }
+
+ if (consumed) {
+ stats.events.fetch_add(1, std::memory_order_relaxed);
+ }
+}
+
+// Mirrors rate_exceeded in abuse_shield.cc.
+template <typename Table>
+bool
+rate_exceeded(Table *tracker, const Rule &rule, const swoc::IPAddr &ip)
+{
+ auto slot = tracker ? tracker->find(ip) : nullptr;
+ return slot && slot->buckets.exceeded(rule.name);
+}
+
+/** The three trackers, as the plugin's g_txn_tracker, g_conn_tracker and
g_h2_tracker. */
+struct Trackers {
+ explicit Trackers(size_t slots)
+ : txn(std::make_unique<TxnTable>(slots)),
conn(std::make_unique<ConnTable>(slots)), h2(std::make_unique<H2Table>(slots))
+ {
+ }
+
+ std::unique_ptr<TxnTable> txn;
+ std::unique_ptr<ConnTable> conn;
+ std::unique_ptr<H2Table> h2;
+};
+
+// Mirrors the rate-rule part of rule_matches and evaluate_rate_rules in
+// abuse_shield.cc, for rules without fingerprints. Returns the matched rule or
+// nullptr.
+const Rule *
+evaluate_rate_rules(const Trackers &trackers, const swoc::IPAddr &ip, const
BenchConfig &config)
+{
+ for (const auto &rule : config.rules) {
+ if (rule.filter.has_fingerprints()) {
+ continue;
+ }
+
+ const auto &f = rule.filter;
+ if (!config.rule_applies_to_ip(rule, ip)) {
+ continue;
+ }
+ if (f.max_req_rate == 0 && f.max_conn_rate == 0 && f.max_h2_error_rate ==
0) {
+ continue;
+ }
+ if (f.max_req_rate > 0 && !rate_exceeded(trackers.txn.get(), rule, ip)) {
+ continue;
+ }
+ if (f.max_conn_rate > 0 && !rate_exceeded(trackers.conn.get(), rule, ip)) {
+ continue;
+ }
+ if (f.max_h2_error_rate > 0 && !rate_exceeded(trackers.h2.get(), rule,
ip)) {
+ continue;
+ }
+ return &rule;
+ }
+ return nullptr;
+}
+
+// Mirrors prune_rule_buckets in abuse_shield.cc as it runs on every
+// handle_maintenance pass.
+void
+prune_rule_buckets(const Trackers &trackers, const BenchConfig &config)
+{
+ auto prune = [&config](auto &table, RateMetric metric) {
+ std::unordered_set<std::string> names;
+ for (const auto &rule : config.rules) {
+ if (metric_rate(rule.filter, metric) > 0) {
+ names.insert(rule.name);
+ }
+ }
+ for (const auto &data : table->data_snapshot()) {
+ data->buckets.prune(names);
+ }
+ };
+ prune(trackers.txn, RateMetric::REQUEST);
+ prune(trackers.conn, RateMetric::CONNECTION);
+ prune(trackers.h2, RateMetric::H2_ERROR);
+}
+
+// Mirrors sync_tracker_stats in abuse_shield.cc. TSStatIntSet becomes a
+// relaxed atomic store.
+template <typename Table>
+void
+sync_tracker_stats(Table *tracker)
+{
+ if (tracker) {
+ g_stat_sink.store(static_cast<int64_t>(tracker->slots_used()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->contests()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->contests_won()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->evictions()),
std::memory_order_relaxed);
+ }
+}
+
+// Mirrors sync_all_tracker_stats in abuse_shield.cc.
+void
+sync_all_tracker_stats(const Trackers &trackers)
+{
+ sync_tracker_stats(trackers.txn.get());
+ sync_tracker_stats(trackers.conn.get());
+ sync_tracker_stats(trackers.h2.get());
+}
+
+/** The @a n th distinct address of a deterministic, scattered sequence.
+ *
+ * Multiplying by an odd constant is a bijection on 32 bits, so distinct @a n
+ * give distinct addresses, spread across the space rather than sequential.
+ */
+swoc::IPAddr
+nth_ip(uint32_t n)
+{
+ uint32_t mixed = n * 2654435761U;
+ if (n % IP6_EVERY == 0) {
+ in6_addr addr{};
+ addr.s6_addr[0] = 0x20;
+ addr.s6_addr[1] = 0x01;
+ addr.s6_addr[2] = 0x0d;
+ addr.s6_addr[3] = 0xb8;
+ std::memcpy(&addr.s6_addr[12], &mixed, sizeof(mixed));
+ std::memcpy(&addr.s6_addr[8], &n, sizeof(n));
Review Comment:
IPv6 address construction copies `uint32_t` values in host byte order, while
the IPv4 path uses `htonl(mixed)`. This makes the generated IPv6 addresses
architecture-endian-dependent (and inconsistent with the IPv4 path), which can
change key distribution and results on big-endian platforms. Convert the 32-bit
values to network byte order before copying (or write the bytes explicitly) to
keep address generation stable across architectures.
##########
plugins/experimental/abuse_shield/unit_tests/benchmark_abuse_shield.cc:
##########
@@ -0,0 +1,671 @@
+/** @file
+
+ Micro-benchmarks for abuse_shield IP tracking: the per-request hook path,
+ the 1 Hz maintenance pass, and eviction from a full table.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
contributor license
+ agreements. See the NOTICE file distributed with this work for additional
information regarding
+ copyright ownership. The ASF licenses this file to you under the Apache
License, Version 2.0
+ (the "License"); you may not use this file except in compliance with the
License. You may obtain
+ a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
distributed under the License
+ is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express
+ or implied. See the License for the specific language governing permissions
and limitations under
+ the License.
+*/
+
+// abuse_shield.cc calls the TS API throughout, so it cannot be linked here.
+// The functions below that carry a "Mirrors" comment copy the loop of the
+// named plugin function with the TS API calls removed. Keep them in step with
+// abuse_shield.cc. Everything they call (UdiTable, RuleBuckets, TokenBucket)
is
+// the real code from ip_data.cc and tsutil/UdiTable.h.
+
+#include "config.h"
+#include "ip_data.h"
+
+#define CATCH_CONFIG_ENABLE_BENCHMARKING
+#include <catch2/catch_test_macros.hpp>
+#include <catch2/benchmark/catch_benchmark.hpp>
+#include <catch2/generators/catch_generators.hpp>
+
+#include <algorithm>
+#include <atomic>
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <random>
+#include <string>
+#include <thread>
+#include <type_traits>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+
+using namespace abuse_shield;
+
+namespace
+{
+// Every tracker entry carries this many rule buckets, one per configured rule
+// that sets a rate for that tracker's metric.
+constexpr int RULES_PER_METRIC = 3;
+
+// One address in this many is IPv6, the rest IPv4.
+constexpr size_t IP6_EVERY = 10;
+
+// Table sizes. Production runs with 100k slots.
+#define BENCH_TABLE_SIZES GENERATE(as<size_t>{}, 10'000, 50'000, 100'000)
+
+// Stands in for TSStatIntSet in sync_tracker_stats.
+std::atomic<int64_t> g_stat_sink{0};
+
+/** The parts of abuse_shield::Config that the hook and maintenance paths read.
+ *
+ * The real Config is built by parsing YAML, which pulls in yaml-cpp and
+ * tsutil. The address spaces are empty, as in a configuration without trusted
+ * or rate-limited IP lists, but they are still searched so the per-request
+ * lookups are paid for.
+ */
+struct BenchConfig {
+ BenchConfig() = default;
+ explicit BenchConfig(std::vector<Rule> rules_in) :
rules(std::move(rules_in)) {}
+
+ std::vector<Rule> rules;
+ swoc::IPSpace<bool> trusted_ips;
+ swoc::IPSpace<bool> rate_limited_req_ips;
+ swoc::IPSpace<bool> rate_limited_conn_ips;
+ swoc::IPSpace<bool> rate_limited_h2_ips;
+
+ // Mirrors Config::is_trusted.
+ bool
+ is_trusted(const swoc::IPAddr &ip) const
+ {
+ return trusted_ips.find(ip) != trusted_ips.end();
+ }
+
+ // Mirrors Config::is_rate_limited_for_metric.
+ bool
+ is_rate_limited_for_metric(const swoc::IPAddr &ip, RateMetric metric) const
+ {
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return rate_limited_req_ips.find(ip) != rate_limited_req_ips.end();
+ case RateMetric::CONNECTION:
+ return rate_limited_conn_ips.find(ip) != rate_limited_conn_ips.end();
+ case RateMetric::H2_ERROR:
+ return rate_limited_h2_ips.find(ip) != rate_limited_h2_ips.end();
+ }
+ return false;
+ }
+
+ // Mirrors Config::rule_applies_to_ip.
+ bool
+ rule_applies_to_ip(const Rule &rule, const swoc::IPAddr &ip) const
+ {
+ if (rule.filter.has_rate_limited_ips()) {
+ return rule.filter.rate_limited_ips->find(ip) !=
rule.filter.rate_limited_ips->end();
+ }
+ if (rule.filter.max_req_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::REQUEST)) {
+ return false;
+ }
+ if (rule.filter.max_conn_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::CONNECTION)) {
+ return false;
+ }
+ if (rule.filter.max_h2_error_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::H2_ERROR)) {
+ return false;
+ }
+ return true;
+ }
+};
+
+/** Build a rule set like the shipped abuse_shield.yaml: each rule limits one
+ * metric. With @a rules_per_metric rules per metric, every tracker entry holds
+ * that many buckets once it has seen traffic.
+ */
+std::vector<Rule>
+make_rules(int rules_per_metric)
+{
+ std::vector<Rule> rules;
+ for (int i = 0; i < rules_per_metric; ++i) {
+ Rule req;
+ req.name = "excessive_requests_" +
std::to_string(i);
+ req.filter.max_req_rate = 100 * (i + 1);
+ req.filter.req_burst_multiplier = 2.0;
+ rules.push_back(req);
+
+ Rule conn;
+ conn.name = "excessive_connections_" +
std::to_string(i);
+ conn.filter.max_conn_rate = 20 * (i + 1);
+ conn.filter.conn_burst_multiplier = 1.5;
+ rules.push_back(conn);
+
+ Rule h2;
+ h2.name = "excessive_h2_errors_" + std::to_string(i);
+ h2.filter.max_h2_error_rate = 10 * (i + 1);
+ h2.filter.h2_burst_multiplier = 1.0;
+ rules.push_back(h2);
+ }
+ return rules;
+}
+
+// Mirrors metric_rate in abuse_shield.cc.
+int
+metric_rate(const RuleFilter &filter, RateMetric metric)
+{
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return filter.max_req_rate;
+ case RateMetric::CONNECTION:
+ return filter.max_conn_rate;
+ case RateMetric::H2_ERROR:
+ return filter.max_h2_error_rate;
+ }
+ return 0;
+}
+
+// Mirrors metric_burst_multiplier in abuse_shield.cc.
+double
+metric_burst_multiplier(const RuleFilter &filter, RateMetric metric)
+{
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return filter.req_burst_multiplier;
+ case RateMetric::CONNECTION:
+ return filter.conn_burst_multiplier;
+ case RateMetric::H2_ERROR:
+ return filter.h2_burst_multiplier;
+ }
+ return 1.0;
+}
+
+// Mirrors the TrackerStats counters that consume_rule_buckets increments.
+struct BenchStats {
+ std::atomic<int64_t> events{0};
+ std::atomic<int64_t> events_untracked{0};
+ std::atomic<int64_t> scan_exhausted{0};
+};
+
+// Mirrors consume_rule_buckets in abuse_shield.cc. TSStatIntIncrement becomes
+// a relaxed atomic increment.
+template <typename Table>
+void
+consume_rule_buckets(Table *tracker, const swoc::IPAddr &ip, const BenchConfig
&config, RateMetric metric, BenchStats &stats,
+ uint64_t error_code = 0)
+{
+ typename Table::data_ptr slot;
+ bool consumed = false;
+
+ for (const auto &rule : config.rules) {
+ int rate = metric_rate(rule.filter, metric);
+ if (rate <= 0 || !config.rule_applies_to_ip(rule, ip)) {
+ continue;
+ }
+
+ if (!slot) {
+ typename Table::ProcessStatus status;
+ slot = tracker->process_event(ip, 1, &status);
+ if (!slot) {
+ stats.events_untracked.fetch_add(1, std::memory_order_relaxed);
+ if (status == Table::ProcessStatus::NO_CANDIDATE) {
+ stats.scan_exhausted.fetch_add(1, std::memory_order_relaxed);
+ }
+ return;
+ }
+ }
+
+ int burst = static_cast<int>(static_cast<double>(rate) *
metric_burst_multiplier(rule.filter, metric));
+ if constexpr (std::is_same_v<typename Table::data_type, H2Data>) {
+ slot->consume(rule.name, rate, burst, error_code);
+ } else {
+ slot->consume(rule.name, rate, burst);
+ }
+ consumed = true;
+ }
+
+ if (consumed) {
+ stats.events.fetch_add(1, std::memory_order_relaxed);
+ }
+}
+
+// Mirrors rate_exceeded in abuse_shield.cc.
+template <typename Table>
+bool
+rate_exceeded(Table *tracker, const Rule &rule, const swoc::IPAddr &ip)
+{
+ auto slot = tracker ? tracker->find(ip) : nullptr;
+ return slot && slot->buckets.exceeded(rule.name);
+}
+
+/** The three trackers, as the plugin's g_txn_tracker, g_conn_tracker and
g_h2_tracker. */
+struct Trackers {
+ explicit Trackers(size_t slots)
+ : txn(std::make_unique<TxnTable>(slots)),
conn(std::make_unique<ConnTable>(slots)), h2(std::make_unique<H2Table>(slots))
+ {
+ }
+
+ std::unique_ptr<TxnTable> txn;
+ std::unique_ptr<ConnTable> conn;
+ std::unique_ptr<H2Table> h2;
+};
+
+// Mirrors the rate-rule part of rule_matches and evaluate_rate_rules in
+// abuse_shield.cc, for rules without fingerprints. Returns the matched rule or
+// nullptr.
+const Rule *
+evaluate_rate_rules(const Trackers &trackers, const swoc::IPAddr &ip, const
BenchConfig &config)
+{
+ for (const auto &rule : config.rules) {
+ if (rule.filter.has_fingerprints()) {
+ continue;
+ }
+
+ const auto &f = rule.filter;
+ if (!config.rule_applies_to_ip(rule, ip)) {
+ continue;
+ }
+ if (f.max_req_rate == 0 && f.max_conn_rate == 0 && f.max_h2_error_rate ==
0) {
+ continue;
+ }
+ if (f.max_req_rate > 0 && !rate_exceeded(trackers.txn.get(), rule, ip)) {
+ continue;
+ }
+ if (f.max_conn_rate > 0 && !rate_exceeded(trackers.conn.get(), rule, ip)) {
+ continue;
+ }
+ if (f.max_h2_error_rate > 0 && !rate_exceeded(trackers.h2.get(), rule,
ip)) {
+ continue;
+ }
+ return &rule;
+ }
+ return nullptr;
+}
+
+// Mirrors prune_rule_buckets in abuse_shield.cc as it runs on every
+// handle_maintenance pass.
+void
+prune_rule_buckets(const Trackers &trackers, const BenchConfig &config)
+{
+ auto prune = [&config](auto &table, RateMetric metric) {
+ std::unordered_set<std::string> names;
+ for (const auto &rule : config.rules) {
+ if (metric_rate(rule.filter, metric) > 0) {
+ names.insert(rule.name);
+ }
+ }
+ for (const auto &data : table->data_snapshot()) {
+ data->buckets.prune(names);
+ }
+ };
+ prune(trackers.txn, RateMetric::REQUEST);
+ prune(trackers.conn, RateMetric::CONNECTION);
+ prune(trackers.h2, RateMetric::H2_ERROR);
+}
+
+// Mirrors sync_tracker_stats in abuse_shield.cc. TSStatIntSet becomes a
+// relaxed atomic store.
+template <typename Table>
+void
+sync_tracker_stats(Table *tracker)
+{
+ if (tracker) {
+ g_stat_sink.store(static_cast<int64_t>(tracker->slots_used()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->contests()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->contests_won()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->evictions()),
std::memory_order_relaxed);
+ }
+}
+
+// Mirrors sync_all_tracker_stats in abuse_shield.cc.
+void
+sync_all_tracker_stats(const Trackers &trackers)
+{
+ sync_tracker_stats(trackers.txn.get());
+ sync_tracker_stats(trackers.conn.get());
+ sync_tracker_stats(trackers.h2.get());
+}
+
+/** The @a n th distinct address of a deterministic, scattered sequence.
+ *
+ * Multiplying by an odd constant is a bijection on 32 bits, so distinct @a n
+ * give distinct addresses, spread across the space rather than sequential.
+ */
+swoc::IPAddr
+nth_ip(uint32_t n)
+{
+ uint32_t mixed = n * 2654435761U;
+ if (n % IP6_EVERY == 0) {
+ in6_addr addr{};
+ addr.s6_addr[0] = 0x20;
+ addr.s6_addr[1] = 0x01;
+ addr.s6_addr[2] = 0x0d;
+ addr.s6_addr[3] = 0xb8;
+ std::memcpy(&addr.s6_addr[12], &mixed, sizeof(mixed));
+ std::memcpy(&addr.s6_addr[8], &n, sizeof(n));
+ return swoc::IPAddr{swoc::IP6Addr{addr}};
+ }
+ return swoc::IPAddr{swoc::IP4Addr{htonl(mixed)}};
+}
+
+std::vector<swoc::IPAddr>
+make_ips(size_t count, uint32_t first = 0)
+{
+ std::vector<swoc::IPAddr> ips;
+ ips.reserve(count);
+ for (size_t i = 0; i < count; ++i) {
+ ips.push_back(nth_ip(first + static_cast<uint32_t>(i)));
+ }
+ return ips;
+}
+
+/** Fill @a table with @a ips, giving each entry one bucket per rule of
+ * @a metric, with the rates those rules configure.
+ */
+template <typename Table>
+void
+populate(Table &table, const std::vector<swoc::IPAddr> &ips, const BenchConfig
&config, RateMetric metric)
+{
+ BenchStats stats;
+ for (const auto &ip : ips) {
+ consume_rule_buckets(&table, ip, config, metric, stats);
+ }
+ REQUIRE(table.slots_used() == ips.size());
+}
+
+/** Put every entry of @a table whose index satisfies @a in_debt into rate debt
+ * on rule @a rule_name. A zero rate never replenishes, so the debt holds for
+ * the whole benchmark.
+ */
+template <typename Table, typename Pred>
+void
+put_in_debt(Table &table, const std::vector<swoc::IPAddr> &ips, const
std::string &rule_name, Pred in_debt)
+{
+ for (size_t i = 0; i < ips.size(); ++i) {
+ if (in_debt(i)) {
+ auto data = table.find(ips[i]);
+ REQUIRE(data);
+ data->buckets.consume(rule_name, 0, 1);
+ data->buckets.consume(rule_name, 0, 1);
+ REQUIRE(data->buckets.has_debt());
+ }
+ }
+}
+
+std::vector<swoc::IPAddr>
+shuffled(std::vector<swoc::IPAddr> ips)
+{
+ std::mt19937 rng(42);
+ std::shuffle(ips.begin(), ips.end(), rng);
+ return ips;
+}
+
+/** Run @a fn(thread_index, op_index) @a ops_per_thread times on each of
+ * @a threads threads that start together.
+ */
+template <typename Fn>
+void
+run_threads(int threads, int ops_per_thread, Fn fn)
+{
+ std::atomic<int> ready{0};
+ std::atomic<bool> go{false};
+ std::vector<std::thread> workers;
+ workers.reserve(threads);
+ for (int t = 0; t < threads; ++t) {
+ workers.emplace_back([&, t]() {
+ ready.fetch_add(1, std::memory_order_relaxed);
+ while (!go.load(std::memory_order_acquire)) {
+ std::this_thread::yield();
+ }
+ for (int op = 0; op < ops_per_thread; ++op) {
+ fn(t, op);
+ }
+ });
+ }
+ while (ready.load(std::memory_order_relaxed) < threads) {
+ std::this_thread::yield();
+ }
+ go.store(true, std::memory_order_release);
+ for (auto &worker : workers) {
+ worker.join();
+ }
+}
+
+} // namespace
+
+// ============================================================================
+// Hook path: TS_EVENT_HTTP_TXN_START on an IP already in the table.
+// ============================================================================
+
+TEST_CASE("abuse_shield request hook path", "[abuse_shield][bench][hot_path]")
+{
+ size_t const slots = BENCH_TABLE_SIZES;
+ BenchConfig const config{make_rules(RULES_PER_METRIC)};
+ Trackers trackers(slots);
+ auto const ips = make_ips(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+
+ // Visit tracked IPs in random order so the table is not walked in cache
order.
+ auto const order = shuffled(ips);
+ BenchStats stats;
+ size_t next = 0;
+
+ // The per-request bucket update alone: one process_event and one consume per
+ // request rule.
+ BENCHMARK("consume_rule_buckets, " + std::to_string(slots) + " slots")
+ {
+ auto const &ip = order[next++ % order.size()];
+ consume_rule_buckets(trackers.txn.get(), ip, config, RateMetric::REQUEST,
stats);
+ return stats.events.load(std::memory_order_relaxed);
+ };
+
+ // What handle_txn_event_impl does for TS_EVENT_HTTP_TXN_START once it has
the
+ // client address: the trusted check, the bucket update, then rule
evaluation.
+ BENCHMARK("TXN_START hook, " + std::to_string(slots) + " slots")
+ {
+ auto const &ip = order[next++ % order.size()];
+ if (config.is_trusted(ip)) {
+ return static_cast<Rule const *>(nullptr);
+ }
+ consume_rule_buckets(trackers.txn.get(), ip, config, RateMetric::REQUEST,
stats);
+ return evaluate_rate_rules(trackers, ip, config);
+ };
+}
+
+TEST_CASE("abuse_shield request hook path under thread contention",
"[abuse_shield][bench][hot_path][threaded]")
+{
+ // The table size matters little here; the shared table mutex does.
+ size_t const slots = 100'000;
+ int const threads = GENERATE(4, 16);
+ int constexpr OPS_PER_THREAD = 20'000;
+ BenchConfig const config{make_rules(RULES_PER_METRIC)};
+ Trackers trackers(slots);
+ auto const ips = make_ips(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+ auto const order = shuffled(ips);
+ BenchStats stats;
+
+ // Each sample is one batch of threads * OPS_PER_THREAD calls; divide the
+ // reported time by OPS_PER_THREAD for the wall time per call on each thread.
+ BENCHMARK(std::to_string(threads) + " threads x " +
std::to_string(OPS_PER_THREAD) + " consume_rule_buckets, distinct IPs")
+ {
+ run_threads(threads, OPS_PER_THREAD, [&](int t, int op) {
+ // Disjoint stripes of the shuffled IPs, one per thread.
+ auto const &ip = order[(static_cast<size_t>(op) * threads + t) %
order.size()];
+ consume_rule_buckets(trackers.txn.get(), ip, config,
RateMetric::REQUEST, stats);
+ });
+ return stats.events.load(std::memory_order_relaxed);
+ };
+
+ BENCHMARK(std::to_string(threads) + " threads x " +
std::to_string(OPS_PER_THREAD) + " consume_rule_buckets, same IP")
+ {
+ run_threads(threads, OPS_PER_THREAD,
+ [&](int, int) { consume_rule_buckets(trackers.txn.get(),
order[0], config, RateMetric::REQUEST, stats); });
+ return stats.events.load(std::memory_order_relaxed);
+ };
+}
+
+// ============================================================================
+// Maintenance: prune_rule_buckets and sync_all_tracker_stats.
+// ============================================================================
+
+TEST_CASE("abuse_shield maintenance prune pass",
"[abuse_shield][bench][maintenance]")
+{
+ size_t const slots = BENCH_TABLE_SIZES;
+ BenchConfig const config{make_rules(RULES_PER_METRIC)};
+ auto const ips = make_ips(slots);
+
+ SECTION("steady state, three trackers populated")
+ {
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, config, RateMetric::CONNECTION);
+ populate(*trackers.h2, ips, config, RateMetric::H2_ERROR);
+
+ // Nothing to remove: every bucket belongs to a configured rule.
+ BENCHMARK("prune pass, 3 trackers x " + std::to_string(slots) + " entries,
nothing removed")
+ {
+ prune_rule_buckets(trackers, config);
+ };
+ }
+
+ SECTION("steady state, request and connection trackers populated")
+ {
+ // A deployment where no client has caused an HTTP/2 error leaves the H2
+ // tracker empty.
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, config, RateMetric::CONNECTION);
+
+ BENCHMARK("prune pass, 2 trackers x " + std::to_string(slots) + " entries,
nothing removed")
+ {
+ prune_rule_buckets(trackers, config);
+ };
+ }
+
+ SECTION("steady state, one rule per metric")
+ {
+ BenchConfig const one_rule{make_rules(1)};
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, one_rule, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, one_rule, RateMetric::CONNECTION);
+
+ BENCHMARK("prune pass, 2 trackers x " + std::to_string(slots) + " entries,
1 rule each, nothing removed")
+ {
+ prune_rule_buckets(trackers, one_rule);
+ };
+ }
+
+ SECTION("one rule removed per metric")
+ {
+ // After a reload drops one rule of each metric, every entry of every
+ // tracker erases one bucket. Removal is one-shot, so each timed run needs
+ // a bucket of its own to remove: the setup gives every entry one
+ // "removed_i" bucket per run on top of the kept rules, and run i removes
+ // its own while keeping those of later runs. With one run per sample,
+ // which is what a pass this long gets, every entry starts with three
+ // buckets and erases one.
+ BenchConfig const kept{make_rules(RULES_PER_METRIC - 1)};
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, kept, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, kept, RateMetric::CONNECTION);
+ populate(*trackers.h2, ips, kept, RateMetric::H2_ERROR);
+
+ BENCHMARK_ADVANCED("prune pass, 3 trackers x " + std::to_string(slots) + "
entries, one bucket removed per entry")
+ (Catch::Benchmark::Chronometer meter)
+ {
+ int const runs = meter.runs();
+ std::vector<BenchConfig> run_configs(runs);
+ for (int run = 0; run < runs; ++run) {
+ run_configs[run].rules = kept.rules;
+ for (int later = run + 1; later < runs; ++later) {
+ for (auto rule : make_rules(1)) {
+ rule.name += "_removed_" + std::to_string(later);
+ run_configs[run].rules.push_back(rule);
+ }
+ }
+ }
+ for (int run = 0; run < runs; ++run) {
+ std::string const suffix = "_removed_" + std::to_string(run);
+ std::string const req = "excessive_requests_0" + suffix;
+ std::string const conn = "excessive_connections_0" + suffix;
+ std::string const h2 = "excessive_h2_errors_0" + suffix;
+ for (const auto &ip : ips) {
+ trackers.txn->find(ip)->buckets.consume(req, 100, 200);
+ trackers.conn->find(ip)->buckets.consume(conn, 20, 30);
+ trackers.h2->find(ip)->buckets.consume(h2, 10, 10);
+ }
Review Comment:
These hard-coded rate/burst values (100/200, 20/30, 10/10) implicitly assume
the exact parameters produced by `make_rules(1)`. If the benchmark rule
construction changes, this setup can silently drift away from the intended
configuration. Consider deriving these values from the actual `Rule` objects
being used (e.g., look up the matching rules in `kept`/`make_rules(1)` and
compute burst via `metric_burst_multiplier`) so the benchmark stays consistent
as rules evolve.
##########
plugins/experimental/abuse_shield/unit_tests/benchmark_abuse_shield.cc:
##########
@@ -0,0 +1,671 @@
+/** @file
+
+ Micro-benchmarks for abuse_shield IP tracking: the per-request hook path,
+ the 1 Hz maintenance pass, and eviction from a full table.
+
+ @section license License
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
contributor license
+ agreements. See the NOTICE file distributed with this work for additional
information regarding
+ copyright ownership. The ASF licenses this file to you under the Apache
License, Version 2.0
+ (the "License"); you may not use this file except in compliance with the
License. You may obtain
+ a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
distributed under the License
+ is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express
+ or implied. See the License for the specific language governing permissions
and limitations under
+ the License.
+*/
+
+// abuse_shield.cc calls the TS API throughout, so it cannot be linked here.
+// The functions below that carry a "Mirrors" comment copy the loop of the
+// named plugin function with the TS API calls removed. Keep them in step with
+// abuse_shield.cc. Everything they call (UdiTable, RuleBuckets, TokenBucket)
is
+// the real code from ip_data.cc and tsutil/UdiTable.h.
+
+#include "config.h"
+#include "ip_data.h"
+
+#define CATCH_CONFIG_ENABLE_BENCHMARKING
+#include <catch2/catch_test_macros.hpp>
+#include <catch2/benchmark/catch_benchmark.hpp>
+#include <catch2/generators/catch_generators.hpp>
+
+#include <algorithm>
+#include <atomic>
+#include <cstdint>
+#include <cstring>
+#include <memory>
+#include <random>
+#include <string>
+#include <thread>
+#include <type_traits>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+
+using namespace abuse_shield;
+
+namespace
+{
+// Every tracker entry carries this many rule buckets, one per configured rule
+// that sets a rate for that tracker's metric.
+constexpr int RULES_PER_METRIC = 3;
+
+// One address in this many is IPv6, the rest IPv4.
+constexpr size_t IP6_EVERY = 10;
+
+// Table sizes. Production runs with 100k slots.
+#define BENCH_TABLE_SIZES GENERATE(as<size_t>{}, 10'000, 50'000, 100'000)
+
+// Stands in for TSStatIntSet in sync_tracker_stats.
+std::atomic<int64_t> g_stat_sink{0};
+
+/** The parts of abuse_shield::Config that the hook and maintenance paths read.
+ *
+ * The real Config is built by parsing YAML, which pulls in yaml-cpp and
+ * tsutil. The address spaces are empty, as in a configuration without trusted
+ * or rate-limited IP lists, but they are still searched so the per-request
+ * lookups are paid for.
+ */
+struct BenchConfig {
+ BenchConfig() = default;
+ explicit BenchConfig(std::vector<Rule> rules_in) :
rules(std::move(rules_in)) {}
+
+ std::vector<Rule> rules;
+ swoc::IPSpace<bool> trusted_ips;
+ swoc::IPSpace<bool> rate_limited_req_ips;
+ swoc::IPSpace<bool> rate_limited_conn_ips;
+ swoc::IPSpace<bool> rate_limited_h2_ips;
+
+ // Mirrors Config::is_trusted.
+ bool
+ is_trusted(const swoc::IPAddr &ip) const
+ {
+ return trusted_ips.find(ip) != trusted_ips.end();
+ }
+
+ // Mirrors Config::is_rate_limited_for_metric.
+ bool
+ is_rate_limited_for_metric(const swoc::IPAddr &ip, RateMetric metric) const
+ {
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return rate_limited_req_ips.find(ip) != rate_limited_req_ips.end();
+ case RateMetric::CONNECTION:
+ return rate_limited_conn_ips.find(ip) != rate_limited_conn_ips.end();
+ case RateMetric::H2_ERROR:
+ return rate_limited_h2_ips.find(ip) != rate_limited_h2_ips.end();
+ }
+ return false;
+ }
+
+ // Mirrors Config::rule_applies_to_ip.
+ bool
+ rule_applies_to_ip(const Rule &rule, const swoc::IPAddr &ip) const
+ {
+ if (rule.filter.has_rate_limited_ips()) {
+ return rule.filter.rate_limited_ips->find(ip) !=
rule.filter.rate_limited_ips->end();
+ }
+ if (rule.filter.max_req_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::REQUEST)) {
+ return false;
+ }
+ if (rule.filter.max_conn_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::CONNECTION)) {
+ return false;
+ }
+ if (rule.filter.max_h2_error_rate > 0 && is_rate_limited_for_metric(ip,
RateMetric::H2_ERROR)) {
+ return false;
+ }
+ return true;
+ }
+};
+
+/** Build a rule set like the shipped abuse_shield.yaml: each rule limits one
+ * metric. With @a rules_per_metric rules per metric, every tracker entry holds
+ * that many buckets once it has seen traffic.
+ */
+std::vector<Rule>
+make_rules(int rules_per_metric)
+{
+ std::vector<Rule> rules;
+ for (int i = 0; i < rules_per_metric; ++i) {
+ Rule req;
+ req.name = "excessive_requests_" +
std::to_string(i);
+ req.filter.max_req_rate = 100 * (i + 1);
+ req.filter.req_burst_multiplier = 2.0;
+ rules.push_back(req);
+
+ Rule conn;
+ conn.name = "excessive_connections_" +
std::to_string(i);
+ conn.filter.max_conn_rate = 20 * (i + 1);
+ conn.filter.conn_burst_multiplier = 1.5;
+ rules.push_back(conn);
+
+ Rule h2;
+ h2.name = "excessive_h2_errors_" + std::to_string(i);
+ h2.filter.max_h2_error_rate = 10 * (i + 1);
+ h2.filter.h2_burst_multiplier = 1.0;
+ rules.push_back(h2);
+ }
+ return rules;
+}
+
+// Mirrors metric_rate in abuse_shield.cc.
+int
+metric_rate(const RuleFilter &filter, RateMetric metric)
+{
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return filter.max_req_rate;
+ case RateMetric::CONNECTION:
+ return filter.max_conn_rate;
+ case RateMetric::H2_ERROR:
+ return filter.max_h2_error_rate;
+ }
+ return 0;
+}
+
+// Mirrors metric_burst_multiplier in abuse_shield.cc.
+double
+metric_burst_multiplier(const RuleFilter &filter, RateMetric metric)
+{
+ switch (metric) {
+ case RateMetric::REQUEST:
+ return filter.req_burst_multiplier;
+ case RateMetric::CONNECTION:
+ return filter.conn_burst_multiplier;
+ case RateMetric::H2_ERROR:
+ return filter.h2_burst_multiplier;
+ }
+ return 1.0;
+}
+
+// Mirrors the TrackerStats counters that consume_rule_buckets increments.
+struct BenchStats {
+ std::atomic<int64_t> events{0};
+ std::atomic<int64_t> events_untracked{0};
+ std::atomic<int64_t> scan_exhausted{0};
+};
+
+// Mirrors consume_rule_buckets in abuse_shield.cc. TSStatIntIncrement becomes
+// a relaxed atomic increment.
+template <typename Table>
+void
+consume_rule_buckets(Table *tracker, const swoc::IPAddr &ip, const BenchConfig
&config, RateMetric metric, BenchStats &stats,
+ uint64_t error_code = 0)
+{
+ typename Table::data_ptr slot;
+ bool consumed = false;
+
+ for (const auto &rule : config.rules) {
+ int rate = metric_rate(rule.filter, metric);
+ if (rate <= 0 || !config.rule_applies_to_ip(rule, ip)) {
+ continue;
+ }
+
+ if (!slot) {
+ typename Table::ProcessStatus status;
+ slot = tracker->process_event(ip, 1, &status);
+ if (!slot) {
+ stats.events_untracked.fetch_add(1, std::memory_order_relaxed);
+ if (status == Table::ProcessStatus::NO_CANDIDATE) {
+ stats.scan_exhausted.fetch_add(1, std::memory_order_relaxed);
+ }
+ return;
+ }
+ }
+
+ int burst = static_cast<int>(static_cast<double>(rate) *
metric_burst_multiplier(rule.filter, metric));
+ if constexpr (std::is_same_v<typename Table::data_type, H2Data>) {
+ slot->consume(rule.name, rate, burst, error_code);
+ } else {
+ slot->consume(rule.name, rate, burst);
+ }
+ consumed = true;
+ }
+
+ if (consumed) {
+ stats.events.fetch_add(1, std::memory_order_relaxed);
+ }
+}
+
+// Mirrors rate_exceeded in abuse_shield.cc.
+template <typename Table>
+bool
+rate_exceeded(Table *tracker, const Rule &rule, const swoc::IPAddr &ip)
+{
+ auto slot = tracker ? tracker->find(ip) : nullptr;
+ return slot && slot->buckets.exceeded(rule.name);
+}
+
+/** The three trackers, as the plugin's g_txn_tracker, g_conn_tracker and
g_h2_tracker. */
+struct Trackers {
+ explicit Trackers(size_t slots)
+ : txn(std::make_unique<TxnTable>(slots)),
conn(std::make_unique<ConnTable>(slots)), h2(std::make_unique<H2Table>(slots))
+ {
+ }
+
+ std::unique_ptr<TxnTable> txn;
+ std::unique_ptr<ConnTable> conn;
+ std::unique_ptr<H2Table> h2;
+};
+
+// Mirrors the rate-rule part of rule_matches and evaluate_rate_rules in
+// abuse_shield.cc, for rules without fingerprints. Returns the matched rule or
+// nullptr.
+const Rule *
+evaluate_rate_rules(const Trackers &trackers, const swoc::IPAddr &ip, const
BenchConfig &config)
+{
+ for (const auto &rule : config.rules) {
+ if (rule.filter.has_fingerprints()) {
+ continue;
+ }
+
+ const auto &f = rule.filter;
+ if (!config.rule_applies_to_ip(rule, ip)) {
+ continue;
+ }
+ if (f.max_req_rate == 0 && f.max_conn_rate == 0 && f.max_h2_error_rate ==
0) {
+ continue;
+ }
+ if (f.max_req_rate > 0 && !rate_exceeded(trackers.txn.get(), rule, ip)) {
+ continue;
+ }
+ if (f.max_conn_rate > 0 && !rate_exceeded(trackers.conn.get(), rule, ip)) {
+ continue;
+ }
+ if (f.max_h2_error_rate > 0 && !rate_exceeded(trackers.h2.get(), rule,
ip)) {
+ continue;
+ }
+ return &rule;
+ }
+ return nullptr;
+}
+
+// Mirrors prune_rule_buckets in abuse_shield.cc as it runs on every
+// handle_maintenance pass.
+void
+prune_rule_buckets(const Trackers &trackers, const BenchConfig &config)
+{
+ auto prune = [&config](auto &table, RateMetric metric) {
+ std::unordered_set<std::string> names;
+ for (const auto &rule : config.rules) {
+ if (metric_rate(rule.filter, metric) > 0) {
+ names.insert(rule.name);
+ }
+ }
+ for (const auto &data : table->data_snapshot()) {
+ data->buckets.prune(names);
+ }
+ };
+ prune(trackers.txn, RateMetric::REQUEST);
+ prune(trackers.conn, RateMetric::CONNECTION);
+ prune(trackers.h2, RateMetric::H2_ERROR);
+}
+
+// Mirrors sync_tracker_stats in abuse_shield.cc. TSStatIntSet becomes a
+// relaxed atomic store.
+template <typename Table>
+void
+sync_tracker_stats(Table *tracker)
+{
+ if (tracker) {
+ g_stat_sink.store(static_cast<int64_t>(tracker->slots_used()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->contests()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->contests_won()),
std::memory_order_relaxed);
+ g_stat_sink.store(static_cast<int64_t>(tracker->evictions()),
std::memory_order_relaxed);
+ }
+}
+
+// Mirrors sync_all_tracker_stats in abuse_shield.cc.
+void
+sync_all_tracker_stats(const Trackers &trackers)
+{
+ sync_tracker_stats(trackers.txn.get());
+ sync_tracker_stats(trackers.conn.get());
+ sync_tracker_stats(trackers.h2.get());
+}
+
+/** The @a n th distinct address of a deterministic, scattered sequence.
+ *
+ * Multiplying by an odd constant is a bijection on 32 bits, so distinct @a n
+ * give distinct addresses, spread across the space rather than sequential.
+ */
+swoc::IPAddr
+nth_ip(uint32_t n)
+{
+ uint32_t mixed = n * 2654435761U;
+ if (n % IP6_EVERY == 0) {
+ in6_addr addr{};
+ addr.s6_addr[0] = 0x20;
+ addr.s6_addr[1] = 0x01;
+ addr.s6_addr[2] = 0x0d;
+ addr.s6_addr[3] = 0xb8;
+ std::memcpy(&addr.s6_addr[12], &mixed, sizeof(mixed));
+ std::memcpy(&addr.s6_addr[8], &n, sizeof(n));
+ return swoc::IPAddr{swoc::IP6Addr{addr}};
+ }
+ return swoc::IPAddr{swoc::IP4Addr{htonl(mixed)}};
+}
+
+std::vector<swoc::IPAddr>
+make_ips(size_t count, uint32_t first = 0)
+{
+ std::vector<swoc::IPAddr> ips;
+ ips.reserve(count);
+ for (size_t i = 0; i < count; ++i) {
+ ips.push_back(nth_ip(first + static_cast<uint32_t>(i)));
+ }
+ return ips;
+}
+
+/** Fill @a table with @a ips, giving each entry one bucket per rule of
+ * @a metric, with the rates those rules configure.
+ */
+template <typename Table>
+void
+populate(Table &table, const std::vector<swoc::IPAddr> &ips, const BenchConfig
&config, RateMetric metric)
+{
+ BenchStats stats;
+ for (const auto &ip : ips) {
+ consume_rule_buckets(&table, ip, config, metric, stats);
+ }
+ REQUIRE(table.slots_used() == ips.size());
+}
+
+/** Put every entry of @a table whose index satisfies @a in_debt into rate debt
+ * on rule @a rule_name. A zero rate never replenishes, so the debt holds for
+ * the whole benchmark.
+ */
+template <typename Table, typename Pred>
+void
+put_in_debt(Table &table, const std::vector<swoc::IPAddr> &ips, const
std::string &rule_name, Pred in_debt)
+{
+ for (size_t i = 0; i < ips.size(); ++i) {
+ if (in_debt(i)) {
+ auto data = table.find(ips[i]);
+ REQUIRE(data);
+ data->buckets.consume(rule_name, 0, 1);
+ data->buckets.consume(rule_name, 0, 1);
+ REQUIRE(data->buckets.has_debt());
+ }
+ }
+}
+
+std::vector<swoc::IPAddr>
+shuffled(std::vector<swoc::IPAddr> ips)
+{
+ std::mt19937 rng(42);
+ std::shuffle(ips.begin(), ips.end(), rng);
+ return ips;
+}
+
+/** Run @a fn(thread_index, op_index) @a ops_per_thread times on each of
+ * @a threads threads that start together.
+ */
+template <typename Fn>
+void
+run_threads(int threads, int ops_per_thread, Fn fn)
+{
+ std::atomic<int> ready{0};
+ std::atomic<bool> go{false};
+ std::vector<std::thread> workers;
+ workers.reserve(threads);
+ for (int t = 0; t < threads; ++t) {
+ workers.emplace_back([&, t]() {
+ ready.fetch_add(1, std::memory_order_relaxed);
+ while (!go.load(std::memory_order_acquire)) {
+ std::this_thread::yield();
+ }
+ for (int op = 0; op < ops_per_thread; ++op) {
+ fn(t, op);
+ }
+ });
+ }
+ while (ready.load(std::memory_order_relaxed) < threads) {
+ std::this_thread::yield();
+ }
+ go.store(true, std::memory_order_release);
+ for (auto &worker : workers) {
+ worker.join();
+ }
+}
+
+} // namespace
+
+// ============================================================================
+// Hook path: TS_EVENT_HTTP_TXN_START on an IP already in the table.
+// ============================================================================
+
+TEST_CASE("abuse_shield request hook path", "[abuse_shield][bench][hot_path]")
+{
+ size_t const slots = BENCH_TABLE_SIZES;
+ BenchConfig const config{make_rules(RULES_PER_METRIC)};
+ Trackers trackers(slots);
+ auto const ips = make_ips(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+
+ // Visit tracked IPs in random order so the table is not walked in cache
order.
+ auto const order = shuffled(ips);
+ BenchStats stats;
+ size_t next = 0;
+
+ // The per-request bucket update alone: one process_event and one consume per
+ // request rule.
+ BENCHMARK("consume_rule_buckets, " + std::to_string(slots) + " slots")
+ {
+ auto const &ip = order[next++ % order.size()];
+ consume_rule_buckets(trackers.txn.get(), ip, config, RateMetric::REQUEST,
stats);
+ return stats.events.load(std::memory_order_relaxed);
+ };
+
+ // What handle_txn_event_impl does for TS_EVENT_HTTP_TXN_START once it has
the
+ // client address: the trusted check, the bucket update, then rule
evaluation.
+ BENCHMARK("TXN_START hook, " + std::to_string(slots) + " slots")
+ {
+ auto const &ip = order[next++ % order.size()];
+ if (config.is_trusted(ip)) {
+ return static_cast<Rule const *>(nullptr);
+ }
+ consume_rule_buckets(trackers.txn.get(), ip, config, RateMetric::REQUEST,
stats);
+ return evaluate_rate_rules(trackers, ip, config);
+ };
+}
+
+TEST_CASE("abuse_shield request hook path under thread contention",
"[abuse_shield][bench][hot_path][threaded]")
+{
+ // The table size matters little here; the shared table mutex does.
+ size_t const slots = 100'000;
+ int const threads = GENERATE(4, 16);
+ int constexpr OPS_PER_THREAD = 20'000;
+ BenchConfig const config{make_rules(RULES_PER_METRIC)};
+ Trackers trackers(slots);
+ auto const ips = make_ips(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+ auto const order = shuffled(ips);
+ BenchStats stats;
+
+ // Each sample is one batch of threads * OPS_PER_THREAD calls; divide the
+ // reported time by OPS_PER_THREAD for the wall time per call on each thread.
+ BENCHMARK(std::to_string(threads) + " threads x " +
std::to_string(OPS_PER_THREAD) + " consume_rule_buckets, distinct IPs")
+ {
+ run_threads(threads, OPS_PER_THREAD, [&](int t, int op) {
+ // Disjoint stripes of the shuffled IPs, one per thread.
+ auto const &ip = order[(static_cast<size_t>(op) * threads + t) %
order.size()];
+ consume_rule_buckets(trackers.txn.get(), ip, config,
RateMetric::REQUEST, stats);
+ });
+ return stats.events.load(std::memory_order_relaxed);
+ };
+
+ BENCHMARK(std::to_string(threads) + " threads x " +
std::to_string(OPS_PER_THREAD) + " consume_rule_buckets, same IP")
+ {
+ run_threads(threads, OPS_PER_THREAD,
+ [&](int, int) { consume_rule_buckets(trackers.txn.get(),
order[0], config, RateMetric::REQUEST, stats); });
+ return stats.events.load(std::memory_order_relaxed);
+ };
+}
+
+// ============================================================================
+// Maintenance: prune_rule_buckets and sync_all_tracker_stats.
+// ============================================================================
+
+TEST_CASE("abuse_shield maintenance prune pass",
"[abuse_shield][bench][maintenance]")
+{
+ size_t const slots = BENCH_TABLE_SIZES;
+ BenchConfig const config{make_rules(RULES_PER_METRIC)};
+ auto const ips = make_ips(slots);
+
+ SECTION("steady state, three trackers populated")
+ {
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, config, RateMetric::CONNECTION);
+ populate(*trackers.h2, ips, config, RateMetric::H2_ERROR);
+
+ // Nothing to remove: every bucket belongs to a configured rule.
+ BENCHMARK("prune pass, 3 trackers x " + std::to_string(slots) + " entries,
nothing removed")
+ {
+ prune_rule_buckets(trackers, config);
+ };
+ }
+
+ SECTION("steady state, request and connection trackers populated")
+ {
+ // A deployment where no client has caused an HTTP/2 error leaves the H2
+ // tracker empty.
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, config, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, config, RateMetric::CONNECTION);
+
+ BENCHMARK("prune pass, 2 trackers x " + std::to_string(slots) + " entries,
nothing removed")
+ {
+ prune_rule_buckets(trackers, config);
+ };
+ }
+
+ SECTION("steady state, one rule per metric")
+ {
+ BenchConfig const one_rule{make_rules(1)};
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, one_rule, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, one_rule, RateMetric::CONNECTION);
+
+ BENCHMARK("prune pass, 2 trackers x " + std::to_string(slots) + " entries,
1 rule each, nothing removed")
+ {
+ prune_rule_buckets(trackers, one_rule);
+ };
+ }
+
+ SECTION("one rule removed per metric")
+ {
+ // After a reload drops one rule of each metric, every entry of every
+ // tracker erases one bucket. Removal is one-shot, so each timed run needs
+ // a bucket of its own to remove: the setup gives every entry one
+ // "removed_i" bucket per run on top of the kept rules, and run i removes
+ // its own while keeping those of later runs. With one run per sample,
+ // which is what a pass this long gets, every entry starts with three
+ // buckets and erases one.
+ BenchConfig const kept{make_rules(RULES_PER_METRIC - 1)};
+ Trackers trackers(slots);
+ populate(*trackers.txn, ips, kept, RateMetric::REQUEST);
+ populate(*trackers.conn, ips, kept, RateMetric::CONNECTION);
+ populate(*trackers.h2, ips, kept, RateMetric::H2_ERROR);
+
+ BENCHMARK_ADVANCED("prune pass, 3 trackers x " + std::to_string(slots) + "
entries, one bucket removed per entry")
+ (Catch::Benchmark::Chronometer meter)
+ {
+ int const runs = meter.runs();
+ std::vector<BenchConfig> run_configs(runs);
+ for (int run = 0; run < runs; ++run) {
+ run_configs[run].rules = kept.rules;
+ for (int later = run + 1; later < runs; ++later) {
+ for (auto rule : make_rules(1)) {
+ rule.name += "_removed_" + std::to_string(later);
+ run_configs[run].rules.push_back(rule);
+ }
+ }
+ }
+ for (int run = 0; run < runs; ++run) {
+ std::string const suffix = "_removed_" + std::to_string(run);
+ std::string const req = "excessive_requests_0" + suffix;
+ std::string const conn = "excessive_connections_0" + suffix;
+ std::string const h2 = "excessive_h2_errors_0" + suffix;
+ for (const auto &ip : ips) {
+ trackers.txn->find(ip)->buckets.consume(req, 100, 200);
+ trackers.conn->find(ip)->buckets.consume(conn, 20, 30);
+ trackers.h2->find(ip)->buckets.consume(h2, 10, 10);
+ }
+ }
+ meter.measure([&](int run) { prune_rule_buckets(trackers,
run_configs[run]); });
Review Comment:
The benchmark setup work here scales with `meter.runs()` and does
substantial allocation and per-entry initialization before timing begins. Even
though it’s outside `meter.measure`, it still repeats per sample and can become
very expensive for smaller table sizes (where Catch may choose more runs),
potentially distorting overall benchmark runtime and the runtime environment
(cache/memory pressure) in ways that affect measured prune behavior. Consider
forcing a fixed/small run count for this case, or restructuring so the heavy
setup runs once (or is proportional only to `slots`), while still ensuring each
measured run removes exactly one bucket per entry.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]