Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
b8fd92e
fix
TAOXUY Mar 6, 2026
4dd73b0
iterate
TAOXUY Mar 6, 2026
4f243b3
stats: fix eviction of stats with active references
TAOXUY Mar 6, 2026
f44561b
format
TAOXUY Mar 8, 2026
32cc03e
fix: restore evictionDisabled and fix test
TAOXUY Mar 8, 2026
b9deaef
fix test
TAOXUY Mar 8, 2026
8ae0f97
fix test
TAOXUY Mar 9, 2026
4e530a7
fix
TAOXUY Mar 9, 2026
d5f87d2
fix
TAOXUY Mar 11, 2026
e5259b5
fix
TAOXUY Mar 11, 2026
7e9176a
fix
TAOXUY Mar 11, 2026
5628f8f
fix
TAOXUY Mar 11, 2026
77dac2d
fix
TAOXUY Mar 12, 2026
8ad7e93
fix
TAOXUY Mar 12, 2026
2995c40
fix
TAOXUY Mar 13, 2026
0ed297f
fix
TAOXUY Mar 16, 2026
464577d
Merge branch 'main' into fixStatDestructor
TAOXUY Mar 16, 2026
cbd6fba
fix
TAOXUY Mar 16, 2026
ee2a3ad
fix
TAOXUY Mar 17, 2026
e6a43a0
Merge branch 'main' into fixStatDestructor
TAOXUY Mar 17, 2026
b28e622
fix
TAOXUY Mar 17, 2026
fe1706c
protect over-subtract
TAOXUY Mar 17, 2026
a836a3a
fix
TAOXUY Mar 18, 2026
f6f870c
fix
TAOXUY Mar 18, 2026
7c910c9
fix
TAOXUY Mar 18, 2026
09e2737
fix
TAOXUY Mar 18, 2026
163fd59
fix
TAOXUY Mar 18, 2026
5b3a9af
fix
TAOXUY Mar 18, 2026
f54bb9f
fix
TAOXUY Mar 18, 2026
b927087
fix
TAOXUY Mar 18, 2026
80ce33d
fix
TAOXUY Mar 18, 2026
154c3b7
Merge branch 'main' into fixStatDestructor
TAOXUY Mar 18, 2026
71c8b8c
fix
TAOXUY Mar 18, 2026
8395f2b
fix
TAOXUY Mar 19, 2026
db26105
fix
TAOXUY Mar 19, 2026
ff3a7e0
fix
TAOXUY Mar 19, 2026
ab3f86e
add key test
TAOXUY Mar 19, 2026
2a8f97d
fix
TAOXUY Mar 19, 2026
1b1e6e0
Update source/extensions/access_loggers/stats/stats.cc
TAOXUY Mar 19, 2026
a9cdc38
fix
TAOXUY Mar 19, 2026
81cca1c
fix
TAOXUY Mar 20, 2026
0c6103d
format
TAOXUY Mar 20, 2026
b8d6c70
fix
TAOXUY Mar 20, 2026
cabad6d
fix
TAOXUY Mar 20, 2026
627aab0
fix
TAOXUY Mar 20, 2026
222c0fa
fix
TAOXUY Mar 20, 2026
f097aad
fix
TAOXUY Mar 21, 2026
713686f
fix
TAOXUY Mar 21, 2026
d15e5ed
fix
TAOXUY Mar 21, 2026
d8af134
fix
TAOXUY Mar 23, 2026
eabbbfd
Merge branch 'main' into fixStatDestructor
TAOXUY Mar 23, 2026
9b6ef12
add test
TAOXUY Mar 24, 2026
808a9c6
test: add memory footprint tests for GaugeKey and AccessLogState and …
TAOXUY Mar 24, 2026
d2d97fa
fix comment
TAOXUY Mar 24, 2026
98ef2b0
Apply suggestions from code review
TAOXUY Mar 25, 2026
8757699
Apply suggestions from code review
TAOXUY Mar 25, 2026
617b60b
add integration test for TCP
TAOXUY Mar 25, 2026
b4bdd87
fix
TAOXUY Mar 25, 2026
e447bea
fix
TAOXUY Mar 25, 2026
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
12 changes: 12 additions & 0 deletions envoy/stats/stats.h
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ class Metric : public RefcountInterface {
*/
virtual bool hidden() const PURE;

/**
* Indicates whether this metric is exempt from being evicted when unused.
*/
virtual bool evictionDisabled() const PURE;

/**
* Sets whether this metric should be exempt from eviction.
* This behaves as a reference count natively. Calling with true increments the
* exemption count, and calling with false decrements it.
*/
virtual void setEvictionDisabled(bool disable) PURE;

/**
* Flags:
* Used: used by all stats types to figure out whether they have been used.
Expand Down
15 changes: 15 additions & 0 deletions source/common/stats/allocator_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ template <class BaseClass> class StatsSharedImpl : public MetricImpl<BaseClass>
void markUnused() override { flags_ &= ~Metric::Flags::Used; }
bool hidden() const override { return flags_ & Metric::Flags::Hidden; }

bool evictionDisabled() const override { return eviction_disabled_count_ > 0; }
void setEvictionDisabled(bool disable) override {
if (disable) {
++eviction_disabled_count_;
} else {
ASSERT(eviction_disabled_count_ > 0);
--eviction_disabled_count_;
}
}

// RefcountInterface
void incRefCount() override { ++ref_count_; }
bool decRefCount() override {
Expand Down Expand Up @@ -135,6 +145,11 @@ template <class BaseClass> class StatsSharedImpl : public MetricImpl<BaseClass>
std::atomic<uint32_t> ref_count_{0};

std::atomic<uint16_t> flags_{0};

// eviction_disabled_count_ keeps track of the number of active references that
// explicitly requested this metric to be exempt from unused eviction. Note that
// this is distinct from ref_count_, which prevents the metric from being destructed.
std::atomic<uint32_t> eviction_disabled_count_{0};
};

class CounterImpl : public StatsSharedImpl<Counter> {
Expand Down
3 changes: 3 additions & 0 deletions source/common/stats/metric_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ template <class BaseClass> class MetricImpl : public BaseClass {
helper_.iterateTagStatNames(fn);
}

bool evictionDisabled() const override { return false; }
void setEvictionDisabled(bool) override {}

const SymbolTable& constSymbolTable() const override {
// Cast our 'this', which is of type `const MetricImpl*` to a non-const
// pointer, so we can use it to call the subclass implementation of
Expand Down
5 changes: 4 additions & 1 deletion source/common/stats/thread_local_store.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1117,8 +1117,11 @@ void ThreadLocalStoreImpl::evictUnused() {
MetricBag metrics(scope->scope_id_);
CentralCacheEntrySharedPtr& central_cache = scope->centralCacheMutableNoThreadAnalysis();
auto filter_unused = []<typename T>(StatNameHashMap<T>& unused_metrics) {
return [&unused_metrics](std::pair<StatName, T> kv) {
return [&unused_metrics](const auto& kv) {
const auto& [name, metric] = kv;
if (metric->evictionDisabled()) {
return false;
}
if (metric->used()) {
metric->markUnused();
return false;
Expand Down
50 changes: 17 additions & 33 deletions source/extensions/access_loggers/stats/stats.cc
Original file line number Diff line number Diff line change
Expand Up @@ -15,45 +15,31 @@ namespace {

class AccessLogState : public StreamInfo::FilterState::Object {
public:
AccessLogState(Stats::ScopeSharedPtr scope) : scope_(std::move(scope)) {}
explicit AccessLogState(Stats::ScopeSharedPtr scope) : scope_(std::move(scope)) {}

// When the request is destroyed, we need to subtract the value from the gauge.
// We need to look up the gauge again in the scope because it might have been evicted.
// The gauge object itself is kept alive by the shared_ptr in the state, so we can access its
// name and tags to re-lookup/re-create it in the scope.
~AccessLogState() override {
for (const auto& [gauge_ptr, state] : inflight_gauges_) {
// TODO(taoxuy): make this as an accessor of the
// Stat class.
Stats::StatNameTagVector tag_names;
state.gauge_->iterateTagStatNames(
[&tag_names](Stats::StatName name, Stats::StatName value) -> bool {
tag_names.emplace_back(name, value);
return true;
});

// Using state.gauge_->statName() directly would be incorrect because it returns the fully
// qualified name (including tags). Passing this full name to scope_->gaugeFromStatName(...)
// would cause the scope to attempt tag extraction on the full name. Since the tags in
// AccessLogState are often dynamic and not configured in the global tag extractors, this
// extraction would likely fail to identify the tags correctly, resulting in a gauge with a
// different identity (the full name as the stat name and no tags).
auto& gauge = scope_->gaugeFromStatNameWithTags(
state.gauge_->tagExtractedStatName(), tag_names, Stats::Gauge::ImportMode::Accumulate);
gauge.sub(state.value_);
state.gauge_ptr_->sub(state.value_);
state.gauge_ptr_->setEvictionDisabled(false);
}
}

void addInflightGauge(Stats::Gauge* gauge, uint64_t value) {
inflight_gauges_.try_emplace(gauge, Stats::GaugeSharedPtr(gauge), value);
void addInflightGauge(const Stats::Gauge* gauge, uint64_t value) {
auto& state = inflight_gauges_[gauge];
if (state.gauge_ptr_ == nullptr) {
state.gauge_ptr_ = const_cast<Stats::Gauge*>(gauge);
state.gauge_ptr_->setEvictionDisabled(true);
}
state.value_ += value;
}

absl::optional<uint64_t> removeInflightGauge(Stats::Gauge* gauge) {
absl::optional<uint64_t> removeInflightGauge(const Stats::Gauge* gauge) {
auto it = inflight_gauges_.find(gauge);
if (it == inflight_gauges_.end()) {
return absl::nullopt;
}
uint64_t value = it->second.value_;
it->second.gauge_ptr_->setEvictionDisabled(false);
inflight_gauges_.erase(it);
return value;
}
Expand All @@ -62,17 +48,15 @@ class AccessLogState : public StreamInfo::FilterState::Object {

private:
struct State {
State(Stats::GaugeSharedPtr gauge, uint64_t value) : gauge_(std::move(gauge)), value_(value) {}

Stats::GaugeSharedPtr gauge_;
uint64_t value_;
uint64_t value_{0};
Stats::Gauge* gauge_ptr_{nullptr};
};

Stats::ScopeSharedPtr scope_;

// The map key holds a raw pointer to the gauge. The value holds a ref-counted pointer to ensure
// the gauge is not destroyed if it is evicted from the stats scope.
absl::flat_hash_map<Stats::Gauge*, State> inflight_gauges_;
// The map key holds a raw pointer to the gauge to enable fast O(1) lookups during
// PAIRED_SUBTRACT.
absl::flat_hash_map<const Stats::Gauge*, State> inflight_gauges_;
};

Formatter::FormatterProviderPtr
Expand Down
70 changes: 70 additions & 0 deletions test/extensions/access_loggers/stats/integration_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -279,5 +279,75 @@ TEST_P(StatsAccessLogIntegrationTest, SubtractWithoutAdd) {
test_server_->waitForGaugeEq("test_stat_prefix.active_requests.request_header_tag.my-tag", 0);
}

TEST_P(StatsAccessLogIntegrationTest, ActiveRequestsGaugeEvictionResetsValueIfUnprotected) {
const std::string config_yaml = R"(
name: envoy.access_loggers.stats
typed_config:
"@type": type.googleapis.com/envoy.extensions.access_loggers.stats.v3.Config
stat_prefix: test_stat_prefix
gauges:
- stat:
name: active_requests
tags:
- name: request_header_tag
value_format: '%REQUEST_HEADER(tag-value)%'
value_fixed: 1
add_subtract:
add_log_type: DownstreamStart
sub_log_type: DownstreamEnd
)";

init(config_yaml, /*autonomous_upstream=*/false,
/*flush_access_log_on_new_request=*/true);

Http::TestRequestHeaderMapImpl request_headers{
{":method", "GET"}, {":authority", "envoyproxy.io"}, {":path", "/"},
{":scheme", "http"}, {"tag-value", "my-eviction-test-tag"},
};

// Request 1: starts gauge at 1.
auto codec_client1 = makeHttpConnection(lookupPort("http"));
auto response1 = codec_client1->makeHeaderOnlyRequest(request_headers);
waitForNextUpstreamRequest();
test_server_->waitForGaugeEq(
"test_stat_prefix.active_requests.request_header_tag.my-eviction-test-tag", 1);

// Simulate eviction from the store.
absl::Notification evict_done;
test_server_->server().dispatcher().post([this, &evict_done]() {
test_server_->statStore().evictUnused();
test_server_->statStore().evictUnused();
evict_done.Notify();
});
evict_done.WaitForNotification();

// Request 2: starts another concurrent request using the same tag.
auto codec_client2 = makeHttpConnection(lookupPort("http"));
auto response2 = codec_client2->makeHeaderOnlyRequest(request_headers);

// Wait for the second request to reach upstream.
// We need to keep track of the second upstream request.
FakeStreamPtr upstream_request2;
FakeHttpConnectionPtr fake_upstream_connection2;
ASSERT_TRUE(fake_upstreams_[0]->waitForHttpConnection(*dispatcher_, fake_upstream_connection2));
ASSERT_TRUE(fake_upstream_connection2->waitForNewStream(*dispatcher_, upstream_request2));
ASSERT_TRUE(upstream_request2->waitForEndStream(*dispatcher_));

// The gauge should be kept even with eviction happened and the active request is 2.
test_server_->waitForGaugeEq(
"test_stat_prefix.active_requests.request_header_tag.my-eviction-test-tag", 2);

// Clean up.
Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}};
upstream_request_->encodeHeaders(response_headers, true);
ASSERT_TRUE(response1->waitForEndStream());
test_server_->waitForGaugeEq(
"test_stat_prefix.active_requests.request_header_tag.my-eviction-test-tag", 1);
upstream_request2->encodeHeaders(response_headers, true);
ASSERT_TRUE(response2->waitForEndStream());
test_server_->waitForGaugeEq(
"test_stat_prefix.active_requests.request_header_tag.my-eviction-test-tag", 0);
}

} // namespace
} // namespace Envoy
64 changes: 26 additions & 38 deletions test/extensions/access_loggers/stats/stats_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class StatsAccessLoggerTest : public testing::Test {
void initialize(const envoy::extensions::access_loggers::stats::v3::Config& config) {
auto* gauge = new NiceMock<MockGaugeWithTags>();
gauge_ = gauge;
ON_CALL(*gauge_, value()).WillByDefault(testing::Return(10));
gauge_ptr_ = Stats::GaugeSharedPtr(gauge_);
gauge_->name_ = "gauge";
gauge_->setTagExtractedName("gauge");
Expand Down Expand Up @@ -637,18 +638,21 @@ TEST_F(StatsAccessLoggerTest, DestructionSubtractsRemainingValue) {

NiceMock<StreamInfo::MockStreamInfo> local_stream_info;

// Called once on log() and once on destruction.
EXPECT_CALL(store_, gauge(_, Stats::Gauge::ImportMode::Accumulate)).Times(2);
// Called once on log().
EXPECT_CALL(store_, gauge(_, Stats::Gauge::ImportMode::Accumulate));
EXPECT_CALL(*gauge_, add(10));
logger_->log(formatter_context_, local_stream_info);

// Expect subtraction on destruction
EXPECT_CALL(*gauge_, sub(10));

// Destroy logger before stream_info to simulate logger config deletion while stream is active
logger_.reset();

// local_stream_info goes out of scope here.
}

TEST_F(StatsAccessLoggerTest, AccessLogStateDestructorReconstructsGauge) {
TEST_F(StatsAccessLoggerTest, AccessLogStateDestructorSubtractsFromSavedGauge) {
const std::string yaml = R"EOF(
stat_prefix: test_stat_prefix
gauges:
Expand Down Expand Up @@ -680,44 +684,28 @@ TEST_F(StatsAccessLoggerTest, AccessLogStateDestructorReconstructsGauge) {

// Initial lookup and add
EXPECT_CALL(*mock_scope, gaugeFromStatNameWithTags(_, _, Stats::Gauge::ImportMode::Accumulate))
.WillOnce(Invoke([&](const Stats::StatName& name, Stats::StatNameTagVectorOptConstRef tags,
Stats::Gauge::ImportMode) -> Stats::Gauge& {
saved_name = name;
if (tags) {
for (const auto& tag : tags->get()) {
saved_tags_strs.emplace_back(store_.symbolTable().toString(tag.first),
store_.symbolTable().toString(tag.second));
}
}
EXPECT_FALSE(saved_tags_strs.empty());
auto* gauge_with_tags = dynamic_cast<MockGaugeWithTags*>(gauge_);
EXPECT_TRUE(gauge_with_tags != nullptr);
gauge_with_tags->setTags(tags->get(), store_.symbolTable());
return *gauge_;
}));
.WillRepeatedly(
Invoke([&](const Stats::StatName& name, Stats::StatNameTagVectorOptConstRef tags,
Stats::Gauge::ImportMode) -> Stats::Gauge& {
saved_name = name;
if (tags) {
for (const auto& tag : tags->get()) {
saved_tags_strs.emplace_back(store_.symbolTable().toString(tag.first),
store_.symbolTable().toString(tag.second));
}
EXPECT_FALSE(saved_tags_strs.empty());
auto* gauge_with_tags = dynamic_cast<MockGaugeWithTags*>(gauge_);
EXPECT_TRUE(gauge_with_tags != nullptr);
gauge_with_tags->setTags(tags->get(), store_.symbolTable());
}
return *gauge_;
}));

EXPECT_CALL(*gauge_, add(10));
logger_->log(formatter_context_, local_stream_info);

// Simulate eviction from scope (or just verify lookup happens again)
// The destructor of AccessLogState should call gaugeFromStatNameWithTags again.
EXPECT_CALL(*mock_scope, gaugeFromStatNameWithTags(_, _, Stats::Gauge::ImportMode::Accumulate))
.WillOnce(Invoke([&](const Stats::StatName& name, Stats::StatNameTagVectorOptConstRef tags,
Stats::Gauge::ImportMode) -> Stats::Gauge& {
EXPECT_EQ(name, saved_name);
EXPECT_TRUE(tags.has_value());
if (tags) {
const auto& tags_vec = tags->get();
// Detailed comparison
EXPECT_EQ(tags_vec.size(), 2);
if (tags_vec.size() == 2) {
EXPECT_EQ(store_.symbolTable().toString(tags_vec[0].first), "tag_name");
EXPECT_EQ(store_.symbolTable().toString(tags_vec[0].second), "200");
EXPECT_EQ(store_.symbolTable().toString(tags_vec[1].first), "another_tag");
EXPECT_EQ(store_.symbolTable().toString(tags_vec[1].second), "value_fixed");
}
}
return *gauge_;
}));
// The destructor of AccessLogState should call sub(10) directly on the saved gauge
// This will trigger a second lookup using gaugeFromString (tags == absl::nullopt).
EXPECT_CALL(*gauge_, sub(10));

// local_stream_info goes out of scope here, triggering AccessLogState destructor.
Expand Down
7 changes: 7 additions & 0 deletions test/integration/http_integration.cc
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ IntegrationCodecClient::IntegrationCodecClient(
}
}

IntegrationCodecClient::~IntegrationCodecClient() {
// The base class owns connection_ and will destroy it after callbacks_ (owned by this class)
// is destroyed. Therefore, we must remove the callbacks from the connection here to prevent
// a use-after-free when connection_ is destroyed.
connection_->removeConnectionCallbacks(callbacks_);
Copy link
Member

Choose a reason for hiding this comment

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

This shouldn't be needed; you probably missed some cleanup in a test you added.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed.

}

void IntegrationCodecClient::flushWrite() {
connection_->dispatcher().run(Event::Dispatcher::RunType::NonBlock);
// NOTE: We should run blocking until all the body data is flushed.
Expand Down
1 change: 1 addition & 0 deletions test/integration/http_integration.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class IntegrationCodecClient : public Http::CodecClientProd {
Network::ClientConnectionPtr&& conn,
Upstream::HostDescriptionConstSharedPtr host_description,
Http::CodecType type, bool wait_till_connected);
~IntegrationCodecClient() override;

IntegrationStreamDecoderPtr makeHeaderOnlyRequest(const Http::RequestHeaderMap& headers);
IntegrationStreamDecoderPtr makeRequestWithBody(const Http::RequestHeaderMap& headers,
Expand Down
2 changes: 2 additions & 0 deletions test/integration/server.h
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ class NotifyingCounter : public Stats::Counter {
bool hidden() const override { return counter_->hidden(); }
SymbolTable& symbolTable() override { return counter_->symbolTable(); }
const SymbolTable& constSymbolTable() const override { return counter_->constSymbolTable(); }
bool evictionDisabled() const override { return counter_->evictionDisabled(); }
void setEvictionDisabled(bool disable) override { counter_->setEvictionDisabled(disable); }

private:
std::unique_ptr<Stats::Counter> counter_;
Expand Down
3 changes: 3 additions & 0 deletions test/mocks/stats/mocks.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ template <class BaseClass> class MockMetric : public BaseClass {
SymbolTable& symbolTable() override { return *symbol_table_; }
const SymbolTable& constSymbolTable() const override { return *symbol_table_; }

bool evictionDisabled() const override { return false; }
void setEvictionDisabled(bool) override {}

// Note: cannot be mocked because it is accessed as a Property in a gmock EXPECT_CALL. This
// creates a deadlock in gmock and is an unintended use of mock functions.
std::string name() const override { return name_.name(); }
Expand Down
Loading