blob: dab8bbdead185b3e06e9bc1f3555040dc3e059aa [file]
// 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.
#include "util/dns_cache.h"
#include <gtest/gtest-message.h>
#include <gtest/gtest-test-part.h>
#include <netdb.h>
#include "common/config.h"
#include "gtest/gtest_pred_impl.h"
namespace doris {
class DNSCacheTest : public testing::Test {
public:
DNSCacheTest() = default;
~DNSCacheTest() override = default;
protected:
void SetUp() override {
_saved_threshold = config::dns_cache_max_consecutive_failures;
_saved_log_every = config::dns_cache_log_every_n_failures;
_saved_negative_ttl = config::dns_cache_negative_ttl_seconds;
}
void TearDown() override {
config::dns_cache_max_consecutive_failures = _saved_threshold;
config::dns_cache_log_every_n_failures = _saved_log_every;
config::dns_cache_negative_ttl_seconds = _saved_negative_ttl;
}
// Build a resolver whose failure behaviour can be flipped at runtime. Failures are
// reported as authoritative NXDOMAIN by default, which is what eviction requires.
static DNSCache::Resolver make_resolver(bool* should_fail, const std::string& ip = "1.2.3.4",
int fail_gai_err = EAI_NONAME) {
return [should_fail, ip, fail_gai_err](const std::string&, std::string& out, bool,
int* gai_err) -> Status {
if (*should_fail) {
if (gai_err != nullptr) {
*gai_err = fail_gai_err;
}
return Status::InternalError("mock failure");
}
if (gai_err != nullptr) {
*gai_err = 0;
}
out = ip;
return Status::OK();
};
}
// Build a resolver that counts its invocations; failures are authoritative NXDOMAIN
// unless `fail_gai_err` says otherwise.
static DNSCache::Resolver make_counting_resolver(bool* should_fail, int* calls,
const std::string& ip = "1.2.3.4",
int fail_gai_err = EAI_NONAME) {
return [should_fail, calls, ip, fail_gai_err](const std::string&, std::string& out, bool,
int* gai_err) -> Status {
++(*calls);
if (*should_fail) {
if (gai_err != nullptr) {
*gai_err = fail_gai_err;
}
return Status::InternalError("mock failure");
}
if (gai_err != nullptr) {
*gai_err = 0;
}
out = ip;
return Status::OK();
};
}
private:
int32_t _saved_threshold = 0;
int32_t _saved_log_every = 0;
int32_t _saved_negative_ttl = 0;
};
// ── existing tests ────────────────────────────────────────────────────────────
// Sanity: localhost resolves successfully and is cached.
TEST_F(DNSCacheTest, resolve_localhost) {
DNSCache cache;
std::string ip;
EXPECT_TRUE(cache.get("localhost", &ip).ok());
EXPECT_FALSE(ip.empty());
// Second call hits the cache fast path and returns the same IP.
std::string ip2;
EXPECT_TRUE(cache.get("localhost", &ip2).ok());
EXPECT_EQ(ip, ip2);
EXPECT_EQ(1u, cache.size_for_test());
}
// Unresolvable hostname on first access returns InternalError and is NOT cached.
TEST_F(DNSCacheTest, first_miss_does_not_cache) {
config::dns_cache_negative_ttl_seconds = 3600;
DNSCache cache;
std::string ip;
Status st = cache.get("this-host-does-not-exist.invalid", &ip);
EXPECT_FALSE(st.ok());
EXPECT_EQ(0u, cache.size_for_test());
// It is tombstoned instead, so the next caller does not pay another getaddrinfo.
EXPECT_EQ(1u, cache.negative_cache_size_for_test());
}
// Repeated successful resolution does not grow the cache and does not accumulate
// any failure state.
TEST_F(DNSCacheTest, success_keeps_cache_stable) {
DNSCache cache;
std::string ip;
for (int i = 0; i < 8; ++i) {
EXPECT_TRUE(cache.get("localhost", &ip).ok());
}
EXPECT_EQ(1u, cache.size_for_test());
}
// The eviction config can be disabled by setting threshold <= 0 (legacy behavior):
// a cached host whose DNS record disappears keeps serving its stale IP forever,
// exactly as it did before this fix. Drive many refresh cycles (well past any
// plausible threshold) and assert the entry is never dropped.
TEST_F(DNSCacheTest, eviction_disabled_when_threshold_zero) {
config::dns_cache_max_consecutive_failures = 0;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
// Populate the cache with one successful resolution.
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
ASSERT_EQ("1.2.3.4", ip);
ASSERT_EQ(1u, cache.size_for_test());
// DNS now fails permanently.
should_fail = true;
// Far more cycles than the default threshold (30) would need to evict.
for (int i = 0; i < 50; ++i) {
cache.refresh_for_test();
ASSERT_EQ(1u, cache.size_for_test())
<< "eviction must be disabled when threshold <= 0 (cycle " << i << ")";
}
// No tombstone was written, and callers still get the stale IP.
EXPECT_EQ(0u, cache.negative_cache_size_for_test());
ip.clear();
EXPECT_TRUE(cache.get("fake-host.test", &ip).ok())
<< "legacy mode must keep serving the cached ip";
EXPECT_EQ("1.2.3.4", ip);
// The failure counter still accumulates; only the eviction action is disabled.
EXPECT_GT(cache.failure_count_for_test("fake-host.test"), 0u);
}
// ── new tests for eviction logic ─────────────────────────────────────────────
// A hostname that was once successfully resolved is evicted from the cache after
// dns_cache_max_consecutive_failures refresh cycles of continuous DNS failure.
TEST_F(DNSCacheTest, evicts_after_threshold) {
config::dns_cache_max_consecutive_failures = 3;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
// Populate the cache with one successful resolution.
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
ASSERT_EQ("1.2.3.4", ip);
ASSERT_EQ(1u, cache.size_for_test());
// Now make DNS fail.
should_fail = true;
// Each refresh_for_test() call is one _refresh_cache iteration.
// The entry must survive the first threshold-1 cycles and disappear on the
// threshold-th cycle.
for (int i = 0; i < 2; ++i) {
cache.refresh_for_test();
EXPECT_EQ(1u, cache.size_for_test()) << "should not be evicted yet (i=" << i << ")";
}
cache.refresh_for_test(); // third failure → threshold reached → eviction
EXPECT_EQ(0u, cache.size_for_test()) << "host should have been evicted after threshold";
}
// One successful resolution resets the failure counter, so a full threshold of
// additional failures is required before the next eviction.
TEST_F(DNSCacheTest, success_resets_failure_count) {
config::dns_cache_max_consecutive_failures = 3;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Accumulate threshold-1 failures — must NOT evict.
should_fail = true;
cache.refresh_for_test();
cache.refresh_for_test();
EXPECT_EQ(1u, cache.size_for_test()) << "should not be evicted yet";
// One success clears the counter.
should_fail = false;
cache.refresh_for_test();
EXPECT_EQ(1u, cache.size_for_test()) << "success should keep the cache entry";
// A full new round of threshold failures is needed before eviction.
should_fail = true;
cache.refresh_for_test();
cache.refresh_for_test();
EXPECT_EQ(1u, cache.size_for_test()) << "still not enough failures after counter reset";
cache.refresh_for_test(); // third failure post-reset → eviction
EXPECT_EQ(0u, cache.size_for_test()) << "evicted after second run of threshold failures";
}
// A hostname that was never successfully cached must not accumulate entries in
// failure_count, regardless of how many times get() is called (fix for 7.2).
TEST_F(DNSCacheTest, failure_count_does_not_grow_for_never_cached_host) {
config::dns_cache_negative_ttl_seconds = 0; // no tombstone, so every call reaches the resolver
int resolver_calls = 0;
auto always_fail = [&resolver_calls](const std::string&, std::string&, bool,
int* gai_err) -> Status {
++resolver_calls;
if (gai_err != nullptr) {
*gai_err = EAI_NONAME;
}
return Status::InternalError("always fails");
};
DNSCache cache(always_fail);
std::string ip;
for (int i = 0; i < 5; ++i) {
EXPECT_FALSE(cache.get("never-cached.test", &ip).ok());
}
EXPECT_EQ(5, resolver_calls) << "with the negative cache disabled every call resolves";
EXPECT_EQ(0u, cache.size_for_test());
EXPECT_EQ(0u, cache.failure_count_for_test("never-cached.test"))
<< "failure_count must not grow for a host that was never successfully resolved";
}
// After a hostname is evicted, subsequent get() calls must not re-accumulate
// entries in failure_count (fix for 7.2).
TEST_F(DNSCacheTest, failure_count_does_not_grow_after_eviction) {
config::dns_cache_max_consecutive_failures = 2;
config::dns_cache_negative_ttl_seconds = 3600; // keep negative cache active
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
// Populate cache, then evict.
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
should_fail = true;
cache.refresh_for_test();
cache.refresh_for_test();
ASSERT_EQ(0u, cache.size_for_test()) << "prerequisite: host must be evicted";
EXPECT_EQ(0u, cache.failure_count_for_test("fake-host.test"))
<< "failure_count must be cleared on eviction";
EXPECT_EQ(1u, cache.negative_cache_size_for_test())
<< "evicted host must be in the negative cache";
// Further get() calls on the evicted host are served from the negative cache
// and must not reach the resolver, so failure_count stays zero.
for (int i = 0; i < 5; ++i) {
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
}
EXPECT_EQ(0u, cache.failure_count_for_test("fake-host.test"))
<< "failure_count must not grow for an evicted host";
}
// Race defense: simulate concurrent eviction happening between _resolve_hostname's
// shared_lock read of cached_ip and the unique_lock used to ++failure_count.
// The injected resolver erases the host while DNS resolution is "in flight",
// mimicking what the refresh thread would do. Under the cache.find() re-check
// added to the unique_lock section, failure_count must NOT be re-introduced.
TEST_F(DNSCacheTest, failure_count_not_reintroduced_on_eviction_race) {
config::dns_cache_max_consecutive_failures = 1000; // disable auto-eviction
DNSCache* cache_ptr = nullptr;
auto racing_resolver = [&cache_ptr](const std::string& host, std::string&, bool,
int* gai_err) -> Status {
// Simulate the refresh thread's _erase() landing during the
// small window when _resolve_hostname holds no lock.
if (cache_ptr != nullptr) {
cache_ptr->_erase(host);
}
if (gai_err != nullptr) {
*gai_err = EAI_NONAME;
}
return Status::InternalError("mock DNS failure during eviction race");
};
DNSCache cache(racing_resolver);
cache_ptr = &cache;
// Pre-populate so cached_ip is non-empty at the shared_lock read,
// bypassing _update (which would re-insert after the racing erase).
{
std::unique_lock<std::shared_mutex> lock(cache.mutex);
cache.cache["racing.test"] = "1.2.3.4";
}
ASSERT_EQ(1u, cache.size_for_test());
// Call _resolve_hostname directly (via friend access) so the caller-side
// re-insert in _update does not mask the behavior we want to verify.
std::string returned = cache._resolve_hostname("racing.test");
// _resolve_hostname returns the cached_ip captured before the race.
EXPECT_EQ("1.2.3.4", returned);
// The racing erase removed the host from cache.
EXPECT_EQ(0u, cache.size_for_test());
// The re-check under unique_lock prevented re-creating a failure_count entry.
EXPECT_EQ(0u, cache.failure_count_for_test("racing.test"))
<< "failure_count must not be re-introduced for a host evicted mid-resolution";
}
// ── negative cache tests ──────────────────────────────────────────────────────
// After a hostname is evicted, get() must return an error immediately without
// invoking the resolver, as long as the negative-cache TTL has not expired.
TEST_F(DNSCacheTest, negative_cache_blocks_resolver_after_eviction) {
config::dns_cache_max_consecutive_failures = 2;
config::dns_cache_negative_ttl_seconds = 3600; // will not expire during test
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
ASSERT_EQ(1, resolver_calls);
// Evict via failures.
should_fail = true;
cache.refresh_for_test();
cache.refresh_for_test();
ASSERT_EQ(0u, cache.size_for_test()) << "prerequisite: host must be evicted";
ASSERT_EQ(1u, cache.negative_cache_size_for_test());
int calls_at_eviction = resolver_calls;
for (int i = 0; i < 5; ++i) {
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
}
EXPECT_EQ(calls_at_eviction, resolver_calls)
<< "resolver must not be called while host is in the negative cache";
}
// Once the negative-cache TTL expires (simulated by clearing the map), get()
// must attempt a fresh resolve; on success the host re-enters the main cache
// and the negative-cache entry is removed.
TEST_F(DNSCacheTest, negative_cache_retries_after_ttl_expiry) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Evict.
should_fail = true;
cache.refresh_for_test();
ASSERT_EQ(0u, cache.size_for_test());
ASSERT_EQ(1u, cache.negative_cache_size_for_test());
// Negative cache blocks the resolver.
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
int calls_while_blocked = resolver_calls;
// Simulate TTL expiry by backdating the entry.
cache._expire_negative_cache_for_test();
EXPECT_EQ(1u, cache.negative_cache_size_for_test()); // entry exists but expired
// DNS recovers.
should_fail = false;
EXPECT_TRUE(cache.get("fake-host.test", &ip).ok());
EXPECT_GT(resolver_calls, calls_while_blocked) << "resolver must be called after TTL expiry";
EXPECT_EQ(1u, cache.size_for_test()) << "host must be re-cached after successful re-resolve";
EXPECT_EQ(0u, cache.negative_cache_size_for_test())
<< "negative cache entry must be removed on successful re-resolve";
}
// A concurrent successful resolution between _update() returning and the
// threshold check must prevent eviction: _erase_if_still_failing() re-reads
// the live failure_count under its own lock so a reset counter is not lost.
TEST_F(DNSCacheTest, concurrent_success_prevents_stale_eviction) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
ASSERT_EQ(1u, cache.size_for_test());
// Simulate: _update() returned failures == threshold, but before _erase()
// was called a concurrent success cleared failure_count.
{
std::unique_lock<std::shared_mutex> lock(cache.mutex);
cache.failure_count["fake-host.test"] = {1, true}; // at threshold, authoritative
cache.failure_count.erase("fake-host.test"); // concurrent success clears it
}
bool erased = cache._erase_if_still_failing("fake-host.test", 1u);
EXPECT_FALSE(erased) << "must not erase when failure_count was concurrently reset to zero";
EXPECT_EQ(1u, cache.size_for_test()) << "host must survive after concurrent success";
EXPECT_EQ(0u, cache.negative_cache_size_for_test());
}
// When _resolve_hostname returns a stale cached IP after a concurrent eviction,
// _update must not reinsert it (which would undo the eviction and clear the
// negative-cache tombstone).
TEST_F(DNSCacheTest, stale_fallback_not_reinserted_after_concurrent_eviction) {
config::dns_cache_max_consecutive_failures = 1000; // disable threshold-based eviction
config::dns_cache_negative_ttl_seconds = 3600;
DNSCache* cache_ptr = nullptr;
auto racing_resolver = [&cache_ptr](const std::string& host, std::string&, bool,
int* gai_err) -> Status {
if (cache_ptr) {
cache_ptr->_erase(host); // evict mid-DNS-call
}
if (gai_err != nullptr) {
*gai_err = EAI_NONAME;
}
return Status::InternalError("mock DNS failure during concurrent eviction");
};
DNSCache cache(racing_resolver);
cache_ptr = &cache;
// Pre-populate so _resolve_hostname reads a non-empty cached_ip before the
// DNS call, then the racing erase fires during the call.
{
std::unique_lock<std::shared_mutex> lock(cache.mutex);
cache.cache["racing.test"] = "1.2.3.4";
}
ASSERT_EQ(1u, cache.size_for_test());
// Drive one refresh cycle: resolver evicts mid-DNS, returns failure.
// _resolve_hostname returns the stale "1.2.3.4". Without the guard _update
// would reinsert it; with the guard it must not.
cache.refresh_for_test();
EXPECT_EQ(0u, cache.size_for_test()) << "stale IP must not be reinserted after eviction";
EXPECT_EQ(1u, cache.negative_cache_size_for_test()) << "host must be in negative cache";
EXPECT_EQ(0u, cache.failure_count_for_test("racing.test"));
}
// After negative-cache TTL expiry and DNS still failing, get() must re-arm the
// negative cache so the retry rate stays bounded at one attempt per TTL period.
TEST_F(DNSCacheTest, negative_cache_rearms_on_continued_failure_after_ttl_expiry) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Evict.
should_fail = true;
cache.refresh_for_test();
ASSERT_EQ(0u, cache.size_for_test());
ASSERT_EQ(1u, cache.negative_cache_size_for_test());
// Simulate TTL expiry by backdating the entry (entry still exists, time is past).
// Use _expire (not _clear) so the entry is visible to get()'s TTL check,
// which sets expired_negative=true and triggers the re-arm path on failure.
cache._expire_negative_cache_for_test();
ASSERT_EQ(1u, cache.negative_cache_size_for_test()); // entry exists but expired
// DNS still failing: one retry is allowed, then negative cache re-arms.
int calls_before = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_GT(resolver_calls, calls_before) << "resolver called after TTL expiry";
EXPECT_EQ(1u, cache.negative_cache_size_for_test())
<< "negative cache re-armed on continued failure";
// Subsequent calls are now blocked without hitting the resolver.
calls_before = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ(calls_before, resolver_calls) << "resolver NOT called while re-armed";
}
// ── Finding 1: mutable TTL is honored by existing tombstones ─────────────────
// Setting dns_cache_negative_ttl_seconds to 0 after eviction must immediately
// disable the negative cache for existing entries.
TEST_F(DNSCacheTest, negative_cache_disabled_when_ttl_set_to_zero) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Evict with TTL=3600.
should_fail = true;
cache.refresh_for_test();
ASSERT_EQ(0u, cache.size_for_test());
ASSERT_EQ(1u, cache.negative_cache_size_for_test());
// Negative cache blocks.
int calls_at_eviction = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ(calls_at_eviction, resolver_calls);
// Now disable negative cache by setting TTL to 0 — simulates config change.
config::dns_cache_negative_ttl_seconds = 0;
should_fail = false;
// get() must now retry immediately (TTL disabled) and succeed.
EXPECT_TRUE(cache.get("fake-host.test", &ip).ok());
EXPECT_GT(resolver_calls, calls_at_eviction) << "resolver must be called when TTL is disabled";
EXPECT_EQ(1u, cache.size_for_test());
}
// Decreasing dns_cache_negative_ttl_seconds applies to existing tombstones:
// a tombstone created with TTL=3600 must honor the new smaller TTL.
TEST_F(DNSCacheTest, negative_cache_honors_decreased_ttl) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Evict — tombstone stores eviction_time = now.
should_fail = true;
cache.refresh_for_test();
ASSERT_EQ(1u, cache.negative_cache_size_for_test());
// Decrease TTL to 1 second and backdate the entry to make it look older.
config::dns_cache_negative_ttl_seconds = 1;
cache._expire_negative_cache_for_test(); // backdate far → past any 1s deadline
should_fail = false;
EXPECT_TRUE(cache.get("fake-host.test", &ip).ok())
<< "reduced TTL must be honored for existing tombstones";
}
// ── Finding 2: single-flight retry ──────────────────────────────────────────
// Only one thread claims the expired negative-cache retry; a second concurrent
// caller sees the re-armed entry and gets the cheap error.
TEST_F(DNSCacheTest, single_flight_retry_after_negative_ttl_expiry) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Evict and expire the tombstone.
should_fail = true;
cache.refresh_for_test();
cache._expire_negative_cache_for_test();
// First get() claims the retry — DNS still fails, re-arm happens.
int calls_before = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ(calls_before + 1, resolver_calls) << "first caller invokes DNS";
// Second get() must NOT invoke DNS — the entry was re-armed by the first call.
calls_before = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ(calls_before, resolver_calls)
<< "second caller must be blocked by the re-armed entry";
}
// ── Finding 4: refresh cleanup doesn't break re-arm ─────────────────────────
// After _refresh_once() runs, the negative-cache tombstone must still be present
// so that get() can recognize the host as evicted and bound retry rate.
TEST_F(DNSCacheTest, refresh_does_not_erase_negative_cache_tombstone) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Evict and expire the tombstone to simulate passage of time.
should_fail = true;
cache.refresh_for_test();
ASSERT_EQ(1u, cache.negative_cache_size_for_test());
cache._expire_negative_cache_for_test();
// Run another refresh cycle — must NOT erase the expired tombstone. The
// tombstone is what keeps get() from issuing a blocking getaddrinfo on every
// call, so it must outlive refresh cycles regardless of age.
cache.refresh_for_test();
EXPECT_EQ(1u, cache.negative_cache_size_for_test())
<< "refresh must not erase negative-cache tombstones";
// get() still recognizes this as an evicted host: one retry then re-arm.
int calls_before = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ(calls_before + 1, resolver_calls) << "one retry after expiry";
// Subsequent call is blocked.
calls_before = resolver_calls;
EXPECT_FALSE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ(calls_before, resolver_calls) << "blocked after re-arm";
}
// ── only authoritative NXDOMAIN may evict ────────────────────────────────────
// A resolver outage reports transient errors (EAI_AGAIN) for every hostname at once.
// Those must never evict: the backends are still alive at their last known IP, and
// dropping the cache would turn a DNS incident into a cluster-wide RPC outage.
TEST_F(DNSCacheTest, transient_failure_never_evicts) {
config::dns_cache_max_consecutive_failures = 3;
config::dns_cache_negative_ttl_seconds = 3600;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail, "1.2.3.4", EAI_AGAIN));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
ASSERT_EQ(1u, cache.size_for_test());
// The resolver is now unreachable, but the host itself is fine.
should_fail = true;
// Far more cycles than the threshold would need if the failures counted.
for (int i = 0; i < 20; ++i) {
cache.refresh_for_test();
ASSERT_EQ(1u, cache.size_for_test())
<< "transient DNS failures must not evict (cycle " << i << ")";
}
EXPECT_EQ(0u, cache.negative_cache_size_for_test()) << "no tombstone for a transient failure";
// The failure counter still advances so the throttled warning stays informative.
EXPECT_EQ(20u, cache.failure_count_for_test("fake-host.test"));
// Callers keep getting the last known good IP - this is the graceful degradation
// that lets the cluster ride out a resolver outage.
ip.clear();
EXPECT_TRUE(cache.get("fake-host.test", &ip).ok());
EXPECT_EQ("1.2.3.4", ip);
}
// Once the resolver comes back and authoritatively answers NXDOMAIN, the host is
// evicted on that cycle - the failures accumulated during the outage still count
// toward the threshold, only the eviction action was withheld.
TEST_F(DNSCacheTest, authoritative_failure_after_transient_evicts) {
config::dns_cache_max_consecutive_failures = 3;
config::dns_cache_negative_ttl_seconds = 3600;
bool should_fail = false;
int fail_gai_err = EAI_AGAIN;
DNSCache cache([&should_fail, &fail_gai_err](const std::string&, std::string& out, bool,
int* gai_err) -> Status {
if (should_fail) {
*gai_err = fail_gai_err;
return Status::InternalError("mock failure");
}
*gai_err = 0;
out = "1.2.3.4";
return Status::OK();
});
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
// Resolver outage: well past the threshold, still no eviction.
should_fail = true;
for (int i = 0; i < 5; ++i) {
cache.refresh_for_test();
}
ASSERT_EQ(1u, cache.size_for_test()) << "prerequisite: transient failures did not evict";
// The resolver recovers and states the name does not exist.
fail_gai_err = EAI_NONAME;
cache.refresh_for_test();
EXPECT_EQ(0u, cache.size_for_test())
<< "an authoritative NXDOMAIN past the threshold must evict";
EXPECT_EQ(1u, cache.negative_cache_size_for_test());
}
// _erase_if_still_failing() re-reads the state under the erase lock, so a host whose
// most recent failure turned transient is spared even if the count is past threshold.
TEST_F(DNSCacheTest, erase_skipped_when_last_failure_is_transient) {
config::dns_cache_max_consecutive_failures = 1;
config::dns_cache_negative_ttl_seconds = 3600;
bool should_fail = false;
DNSCache cache(make_resolver(&should_fail));
std::string ip;
ASSERT_TRUE(cache.get("fake-host.test", &ip).ok());
ASSERT_EQ(1u, cache.size_for_test());
{
std::unique_lock<std::shared_mutex> lock(cache.mutex);
// Past the threshold, but the latest failure was a resolver problem.
cache.failure_count["fake-host.test"] = {5, false};
}
EXPECT_FALSE(cache._erase_if_still_failing("fake-host.test", 1u))
<< "must not erase when the most recent failure was not authoritative";
EXPECT_EQ(1u, cache.size_for_test());
EXPECT_EQ(0u, cache.negative_cache_size_for_test());
}
// ── negative cache covers hosts that never resolved ──────────────────────────
// A hostname that has never resolved never enters `cache`, so it never reaches the
// eviction path. It must still be tombstoned, otherwise every get() pays a full
// blocking getaddrinfo - and many of those calls run on bthreads.
TEST_F(DNSCacheTest, first_miss_is_negative_cached) {
config::dns_cache_negative_ttl_seconds = 3600;
int resolver_calls = 0;
bool should_fail = true;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
EXPECT_FALSE(cache.get("never-resolved.test", &ip).ok());
EXPECT_EQ(1, resolver_calls) << "the first call must actually try to resolve";
EXPECT_EQ(0u, cache.size_for_test());
EXPECT_EQ(1u, cache.negative_cache_size_for_test())
<< "a host that never resolved must still be tombstoned";
// Every later call is served from the negative cache without touching DNS.
for (int i = 0; i < 5; ++i) {
EXPECT_FALSE(cache.get("never-resolved.test", &ip).ok());
}
EXPECT_EQ(1, resolver_calls) << "resolver must not be called again within the TTL";
EXPECT_EQ(0u, cache.failure_count_for_test("never-resolved.test"))
<< "failure_count must stay empty for a host that was never cached";
// Once the TTL lapses and DNS starts working, the host is picked up normally.
cache._expire_negative_cache_for_test();
should_fail = false;
EXPECT_TRUE(cache.get("never-resolved.test", &ip).ok());
EXPECT_EQ("1.2.3.4", ip);
EXPECT_EQ(1u, cache.size_for_test());
EXPECT_EQ(0u, cache.negative_cache_size_for_test());
}
// Setting the TTL to 0 keeps the legacy behavior: no tombstone for a first miss.
TEST_F(DNSCacheTest, first_miss_not_negative_cached_when_ttl_disabled) {
config::dns_cache_negative_ttl_seconds = 0;
int resolver_calls = 0;
bool should_fail = true;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
for (int i = 0; i < 3; ++i) {
EXPECT_FALSE(cache.get("never-resolved.test", &ip).ok());
}
EXPECT_EQ(0u, cache.negative_cache_size_for_test());
EXPECT_EQ(3, resolver_calls) << "every call resolves when the negative cache is disabled";
}
// ── shutdown responsiveness ──────────────────────────────────────────────────
// _refresh_once() must bail out as soon as the stop flag is set. Each _update() inside
// it blocks in getaddrinfo, so a cycle over a cluster with dead DNS can run for minutes
// and would otherwise hold up ~DNSCache()'s join() for exactly that long.
TEST_F(DNSCacheTest, refresh_stops_early_when_stopping) {
config::dns_cache_max_consecutive_failures = 0; // isolate: no eviction in play
int resolver_calls = 0;
bool should_fail = false;
DNSCache cache(make_counting_resolver(&should_fail, &resolver_calls));
std::string ip;
ASSERT_TRUE(cache.get("host-a.test", &ip).ok());
ASSERT_EQ(1u, cache.size_for_test());
int calls_before = resolver_calls;
cache.stop_refresh = true;
cache.refresh_for_test();
EXPECT_EQ(calls_before, resolver_calls)
<< "a refresh cycle must not start resolving once the stop flag is set";
EXPECT_EQ(1u, cache.size_for_test()) << "bailing out must leave the cache untouched";
}
} // end of namespace doris