This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new a27bf6f179f [fix](dns-cache) evict hostnames that fail to resolve
repeatedly (#63363)
a27bf6f179f is described below
commit a27bf6f179f7157a0e59b3bdf047f294cbb3ed14
Author: zhaorongsheng <[email protected]>
AuthorDate: Mon Aug 3 09:48:10 2026 +0800
[fix](dns-cache) evict hostnames that fail to resolve repeatedly (#63363)
Proposed changes
Issue Number: close #63358
DNSCache currently never evicts an entry once it has been inserted. When
a backend host is permanently dropped from the cluster and its DNS
record is removed, every other BE in the cluster keeps:
1. logging failed to get ip from host: <removed> every 60 s,
indefinitely (from DNSCache::_refresh_cache -> hostname_to_ipv4);
2. handing the stale cached IP back to brpc through BrpcClientCache /
ClientCache, which then keeps emitting Fail to wait EPOLLOUT ...
Connection timed out at the brpc socket layer.
This PR adds a simple consecutive-failure counter to DNSCache. When the
counter reaches a configurable threshold, the entry is removed from the
cache so that callers no longer get a stale IP and the refresh thread
stops logging about
it. WARNING logs for the same host are also throttled to avoid flooding
be.WARNING.
Configs introduced
Name: dns_cache_max_consecutive_failures
Type: mInt32
Default: 30
Behavior: Evict a hostname after this many consecutive resolution
failures. At the default 60 s refresh interval, that means ~30 minutes
of grace. Set <= 0 to disable eviction (legacy behavior).
────────────────────────────────────────
Name: dns_cache_log_every_n_failures
Type: mInt32
Default: 60
Behavior: Throttle the Failed to resolve ... use cached ip warning to
once per N failures per hostname. Set <= 1 to log every failure (legacy
behavior).
Both are mutable so operators can tune without restarting BE.
Backward compatibility
Setting dns_cache_max_consecutive_failures = 0 and
dns_cache_log_every_n_failures = 1 reproduces exactly the pre-PR
behavior. Successful resolution clears the failure counter, so transient
DNS hiccups don't accumulate across hours. No
public API or wire format changes.
Further comments
- I deliberately did not touch the FE-side DNSCache.java. If the same
fix is wanted on FE, happy to send a follow-up PR.
- The eviction threshold default (30) is conservative; please push back
if you'd prefer a smaller / larger default. Operators with very flaky
DNS can lower it via the mutable config without redeploying.
---
Checklist
- I have read the Contributing document.
- I have created an issue (#63358) on (or commented on) the related
issue.
- I have added unit tests for my change.
- All new and existing tests passed (verified locally → fails to build
on macOS due to unrelated contrib/openblas issue; rely on CI).
- My change requires a change to the documentation. — No (config doc
auto-generated)
---------
Signed-off-by: zhaorongsheng <[email protected]>
Co-authored-by: zhaorongsheng <[email protected]>
Co-authored-by: morningman <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
be/src/cloud/cloud_warm_up_manager.cpp | 2 +-
be/src/common/config.cpp | 8 +
be/src/common/config.h | 33 ++
be/src/util/brpc_client_cache.h | 8 +
be/src/util/dns_cache.cpp | 309 +++++++++++--
be/src/util/dns_cache.h | 107 ++++-
be/src/util/network_util.cpp | 35 +-
be/src/util/network_util.h | 16 +-
be/test/util/dns_cache_test.cpp | 819 +++++++++++++++++++++++++++++++++
9 files changed, 1287 insertions(+), 50 deletions(-)
diff --git a/be/src/cloud/cloud_warm_up_manager.cpp
b/be/src/cloud/cloud_warm_up_manager.cpp
index dc7ddc01b59..9ede36b9883 100644
--- a/be/src/cloud/cloud_warm_up_manager.cpp
+++ b/be/src/cloud/cloud_warm_up_manager.cpp
@@ -986,7 +986,7 @@ void CloudWarmUpManager::_recycle_cache(int64_t tablet_id,
if (!status.ok()) {
LOG(WARNING) << "failed to get ip from host " <<
replica.replica.host << ": "
<< status.to_string();
- return;
+ continue;
}
}
std::string brpc_addr = get_host_port(host, replica.replica.brpc_port);
diff --git a/be/src/common/config.cpp b/be/src/common/config.cpp
index 3065d5b0fba..dede5133117 100644
--- a/be/src/common/config.cpp
+++ b/be/src/common/config.cpp
@@ -285,6 +285,14 @@ DEFINE_mInt32(download_binlog_meta_timeout_ms, "30000");
// the interval time(seconds) for agent report index policy to FE
DEFINE_mInt32(report_index_policy_interval_seconds, "10");
+// DNS cache: throttle "use cached ip" warning to once per N failures per host.
+DEFINE_mInt32(dns_cache_log_every_n_failures, "10");
+// DNS cache: evict a hostname after this many consecutive resolution failures.
+DEFINE_mInt32(dns_cache_max_consecutive_failures, "30");
+// DNS cache: after eviction, block re-resolve attempts for this many seconds.
+// Set <= 0 to disable the negative cache (legacy behavior).
+DEFINE_mInt32(dns_cache_negative_ttl_seconds, "60");
+
DEFINE_String(sys_log_dir, "");
DEFINE_String(user_function_dir, "${DORIS_HOME}/lib/udf");
// INFO, WARNING, ERROR, FATAL
diff --git a/be/src/common/config.h b/be/src/common/config.h
index ec48e288fec..9954f3b3e41 100644
--- a/be/src/common/config.h
+++ b/be/src/common/config.h
@@ -342,6 +342,39 @@ DECLARE_mInt32(download_binlog_meta_timeout_ms);
// the interval time(seconds) for agent report index policy to FE
DECLARE_mInt32(report_index_policy_interval_seconds);
+// DNS cache: log the "Failed to resolve hostname ... use cached ip" warning
+// only once per N consecutive failures for the same hostname, to avoid
+// flooding be.WARNING. Set <= 1 to log every failure (legacy behavior).
+// Should be set <= dns_cache_max_consecutive_failures, otherwise only the
+// first-failure log is ever emitted before a host is evicted.
+DECLARE_mInt32(dns_cache_log_every_n_failures);
+
+// DNS cache: evict a hostname after this many consecutive resolution failures.
+// At the default refresh interval of 60s, the default value of 30 means a
+// hostname that was once successfully resolved is evicted after ~30 minutes of
+// being un-resolvable. Hostnames that have never been successfully resolved
are
+// not tracked and are unaffected by this threshold.
+// Eviction additionally requires the most recent failure to be an
authoritative
+// NXDOMAIN (getaddrinfo returning EAI_NONAME), i.e. the resolver positively
+// stating that the name does not exist. Transient failures such as EAI_AGAIN
+// (resolver unreachable or timed out) never evict, so a DNS-server outage
+// degrades to serving the last known IP instead of emptying the cache for
every
+// hostname at once and turning a DNS incident into a cluster-wide RPC outage.
+// Set <= 0 to disable eviction (legacy behavior, kept for backward
compatibility).
+DECLARE_mInt32(dns_cache_max_consecutive_failures);
+
+// DNS cache: seconds to suppress re-resolve attempts for a hostname that could
+// not be resolved -- either because it was evicted after repeated failures, or
+// because it never resolved in the first place. During this window get()
+// returns an error immediately (no blocking getaddrinfo) so request threads
are
+// not stalled while the backend is being drained or while a bad hostname is
+// being retried.
+// This also bounds recovery latency: the refresh thread does not retry
hostnames
+// that are no longer in the cache, so a host comes back only when a caller's
+// get() runs after this TTL expires (one such retry per host per TTL).
+// Set <= 0 to disable the negative cache.
+DECLARE_mInt32(dns_cache_negative_ttl_seconds);
+
// deprecated, use env var LOG_DIR in be.conf
DECLARE_String(sys_log_dir);
// for udf
diff --git a/be/src/util/brpc_client_cache.h b/be/src/util/brpc_client_cache.h
index 993838699e8..aa0ce4524e0 100644
--- a/be/src/util/brpc_client_cache.h
+++ b/be/src/util/brpc_client_cache.h
@@ -174,6 +174,14 @@ public:
Status status = dns_cache->get(host, &realhost);
if (!status.ok()) {
LOG(WARNING) << "failed to get ip from host:" <<
status.to_string();
+ // The hostname is no longer resolvable, which normally means
the backend
+ // was dropped from the cluster. Returning early is not
enough: any stub
+ // cached under this host:port still holds a brpc Channel
bound to the last
+ // resolved (now dead) IP, and brpc keeps health-checking that
socket
+ // forever, which is the source of the endless
+ // "Fail to wait EPOLLOUT ... Connection timed out" warnings.
Drop it here
+ // so the socket is closed along with the last reference to
the stub.
+ _stub_map.erase(fmt::format("{}:{}", host, port));
return nullptr;
}
}
diff --git a/be/src/util/dns_cache.cpp b/be/src/util/dns_cache.cpp
index 0ea794872c1..9a4883bc399 100644
--- a/be/src/util/dns_cache.cpp
+++ b/be/src/util/dns_cache.cpp
@@ -17,6 +17,13 @@
#include "util/dns_cache.h"
+#include <netdb.h>
+
+#include <algorithm>
+#include <atomic>
+#include <unordered_set>
+
+#include "common/config.h"
#include "service/backend_options.h"
#include "util/network_util.h"
@@ -26,14 +33,21 @@ DNSCache::DNSCache() {
refresh_thread = std::thread(&DNSCache::_refresh_cache, this);
}
+DNSCache::DNSCache(Resolver resolver) : _resolver(std::move(resolver)) {}
+
DNSCache::~DNSCache() {
- stop_refresh = true;
+ {
+ std::lock_guard<std::mutex> lk(_cv_mutex);
+ stop_refresh = true;
+ }
+ _cv.notify_all();
if (refresh_thread.joinable()) {
refresh_thread.join();
}
}
Status DNSCache::get(const std::string& hostname, std::string* ip) {
+ bool has_negative_entry = false;
{
std::shared_lock<std::shared_mutex> lock(mutex);
auto it = cache.find(hostname);
@@ -41,20 +55,67 @@ Status DNSCache::get(const std::string& hostname,
std::string* ip) {
*ip = it->second;
return Status::OK();
}
+ auto neg_it = _negative_cache.find(hostname);
+ if (neg_it != _negative_cache.end()) {
+ int32_t ttl = config::dns_cache_negative_ttl_seconds;
+ if (ttl > 0) {
+ auto deadline = neg_it->second + std::chrono::seconds(ttl);
+ if (std::chrono::steady_clock::now() < deadline) {
+ // No stack trace: this is an expected steady state, not
an anomaly, and
+ // it is returned once per caller request for as long as
the host stays
+ // unresolvable. Capturing a stack here would make
Status::Error() log a
+ // WARNING per call (and every caller logs
status.to_string() again),
+ // recreating exactly the be.WARNING flood this cache
exists to stop.
+ return Status::InternalError<false>(
+ "Hostname {} is in negative DNS cache (recently
evicted or "
+ "unresolvable), skipping resolve",
+ hostname);
+ }
+ }
+ has_negative_entry = true;
+ }
}
- // Update if not found
- RETURN_IF_ERROR(_update(hostname));
- {
- std::shared_lock<std::shared_mutex> lock(mutex);
- *ip = cache[hostname];
- return Status::OK();
+
+ // If the host was in the negative cache with an expired (or disabled) TTL,
+ // claim the single-flight retry under unique_lock before the blocking DNS
+ // call. Re-arming the eviction_time to now() makes concurrent callers see
+ // an unexpired entry, bounding retries to one per host per TTL period.
+ if (has_negative_entry) {
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ auto neg_it = _negative_cache.find(hostname);
+ if (neg_it != _negative_cache.end()) {
+ int32_t ttl = config::dns_cache_negative_ttl_seconds;
+ if (ttl <= 0) {
+ _negative_cache.erase(neg_it);
+ } else {
+ auto deadline = neg_it->second + std::chrono::seconds(ttl);
+ if (std::chrono::steady_clock::now() >= deadline) {
+ neg_it->second = std::chrono::steady_clock::now();
+ } else {
+ // Lost the single-flight race; see above for why this
carries no stack.
+ return Status::InternalError<false>(
+ "Hostname {} is in negative DNS cache (recently
evicted or "
+ "unresolvable), skipping resolve",
+ hostname);
+ }
+ }
+ }
}
+
+ // First access (or negative TTL expired): resolve and populate the cache.
+ // Consume the IP returned by _update() directly to avoid a second cache
+ // lookup — operator[] under a shared_lock would mutate the map and could
+ // reinsert an empty entry if a concurrent refresh cycle evicted the
hostname
+ // between _update() and here.
+ return _update(hostname, nullptr, ip);
}
// Resolve hostname to IP address, similar to Java's DNSCache.resolveHostname.
// If resolution fails, falls back to cached IP if available.
// Returns the resolved IP, or cached IP on failure, or empty string if no
cache available.
-std::string DNSCache::_resolve_hostname(const std::string& hostname) {
+// *is_fresh (if non-null) is set to true when DNS returned a live result,
false
+// when the IP comes from the stale cached fallback path.
+std::string DNSCache::_resolve_hostname(const std::string& hostname, bool*
is_fresh) {
// Get cached IP first (if any)
std::string cached_ip;
{
@@ -67,55 +128,245 @@ std::string DNSCache::_resolve_hostname(const
std::string& hostname) {
// Try to resolve hostname
std::string resolved_ip;
- Status status = hostname_to_ip(hostname, resolved_ip,
BackendOptions::is_bind_ipv6());
+ int gai_err = 0;
+ Status status =
+ _resolver ? _resolver(hostname, resolved_ip,
BackendOptions::is_bind_ipv6(), &gai_err)
+ : hostname_to_ip(hostname, resolved_ip,
BackendOptions::is_bind_ipv6(),
+ &gai_err);
if (!status.ok() || resolved_ip.empty()) {
- // Resolution failed
+ if (is_fresh) {
+ *is_fresh = false;
+ }
+ // EAI_NONAME is the resolver authoritatively answering "this name
does not exist",
+ // which is the only evidence that a backend is really gone.
Everything else
+ // (EAI_AGAIN = resolver unreachable or timed out, EAI_SYSTEM,
EAI_FAIL, ...) means
+ // DNS itself is unhealthy while the host is most likely still up at
its last known
+ // address, so those failures must never lead to eviction — otherwise
a resolver
+ // outage would wipe every hostname at once and turn a DNS incident
into a
+ // cluster-wide RPC outage.
+ const bool authoritative = (gai_err == EAI_NONAME);
if (!cached_ip.empty()) {
- LOG(WARNING) << "Failed to resolve hostname " << hostname
- << ", use cached ip: " << cached_ip;
+ // Only track failure counts for hosts that are currently in the
cache.
+ // Hosts that were never cached or have already been evicted are
not
+ // tracked, which prevents unbounded growth of failure_count.
+ uint32_t failures = 0;
+ {
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ // Re-check that the host is still cached under the
unique_lock:
+ // it may have been evicted by the refresh thread between our
+ // earlier shared_lock read of cached_ip and now
(hostname_to_ip
+ // can block for seconds on DNS timeout, widening the window).
+ // Skipping the bump here preserves keys(failure_count) ⊆
keys(cache).
+ if (cache.find(hostname) != cache.end()) {
+ FailureState& state = failure_count[hostname];
+ // The counter tracks failures of any kind so the
throttled log below
+ // stays informative during a resolver outage; only
`last_authoritative`
+ // gates eviction.
+ failures = ++state.count;
+ state.last_authoritative = authoritative;
+ }
+ }
+ // Throttle the log: only every N failures or the first failure.
+ if (failures > 0) {
+ int32_t every_n = std::max(1,
config::dns_cache_log_every_n_failures);
+ if (failures == 1 || failures % static_cast<uint32_t>(every_n)
== 0) {
+ LOG(WARNING) << "Failed to resolve hostname " << hostname
+ << " (consecutive failures: " << failures <<
", error: "
+ << (authoritative ? "NXDOMAIN, host is gone"
+ : "transient, DNS
unhealthy")
+ << "), use cached ip: " << cached_ip;
+ }
+ }
return cached_ip;
} else {
- LOG(WARNING) << "Failed to resolve hostname " << hostname << ", no
cached ip available";
+ // Throttle to avoid flooding be.WARNING when callers repeatedly
+ // query an evicted or never-resolvable hostname. This branch
+ // deliberately does not maintain a per-hostname counter (that
+ // would break the keys(failure_count) ⊆ keys(cache) invariant),
+ // so the throttle is a coarse global rate limit shared across
+ // all hostnames hitting this code path.
+ static std::atomic<uint64_t> no_cache_warn_counter {0};
+ uint64_t n = no_cache_warn_counter.fetch_add(1,
std::memory_order_relaxed) + 1;
+ int32_t every_n = std::max(1,
config::dns_cache_log_every_n_failures);
+ if (n == 1 || n % static_cast<uint64_t>(every_n) == 0) {
+ LOG(WARNING) << "Failed to resolve hostname " << hostname
+ << ", no cached ip available";
+ }
return "";
}
}
+ // Resolution succeeded - clear failure counter for this hostname.
+ if (is_fresh) {
+ *is_fresh = true;
+ }
+ {
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ failure_count.erase(hostname);
+ }
return resolved_ip;
}
-Status DNSCache::_update(const std::string& hostname) {
- std::string real_ip = _resolve_hostname(hostname);
+void DNSCache::_evict_locked(const std::string& hostname) {
+ cache.erase(hostname);
+ failure_count.erase(hostname);
+ int32_t ttl = config::dns_cache_negative_ttl_seconds;
+ if (ttl > 0) {
+ _negative_cache[hostname] = std::chrono::steady_clock::now();
+ }
+}
+
+void DNSCache::_remember_unresolvable(const std::string& hostname) {
+ int32_t ttl = config::dns_cache_negative_ttl_seconds;
+ if (ttl <= 0) {
+ return;
+ }
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ // try_emplace, not operator[]: get()'s single-flight path may have just
re-armed this
+ // tombstone to now(); overwriting it would be harmless there but would
also let two
+ // callers racing on the same host each reset the deadline, loosening the
rate limit.
+ _negative_cache.try_emplace(hostname, std::chrono::steady_clock::now());
+}
+
+void DNSCache::_erase(const std::string& hostname) {
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ _evict_locked(hostname);
+}
+
+bool DNSCache::_erase_if_still_failing(const std::string& hostname, uint32_t
threshold) {
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ auto fc_it = failure_count.find(hostname);
+ if (fc_it == failure_count.end() || fc_it->second.count < threshold ||
+ !fc_it->second.last_authoritative) {
+ // Either a concurrent successful resolution cleared or reset the
counter between
+ // _update() returning and this call — do not erase a now-healthy
entry — or the
+ // most recent failure was transient (resolver unreachable) rather
than an
+ // authoritative NXDOMAIN, in which case the host is probably still
alive.
+ return false;
+ }
+ _evict_locked(hostname);
+ return true;
+}
+
+Status DNSCache::_update(const std::string& hostname, FailureState* out_state,
+ std::string* out_ip) {
+ bool is_fresh = false;
+ std::string real_ip = _resolve_hostname(hostname, &is_fresh);
if (real_ip.empty()) {
- return Status::InternalError("Failed to resolve hostname {} and no
cached ip available",
- hostname);
+ if (out_state) {
+ *out_state = FailureState {};
+ }
+ if (out_ip) {
+ out_ip->clear();
+ }
+ // The host could not be resolved and has no cached IP to fall back
on, so it never
+ // entered `cache` and will therefore never reach the eviction path
that writes a
+ // tombstone. Record one here as well: otherwise every single get() on
a hostname
+ // that has never resolved (a typo in the FE, a backend registered
before its DNS
+ // record propagated) pays a full blocking getaddrinfo, and many of
those calls run
+ // on bthreads where long blocking is especially costly.
+ _remember_unresolvable(hostname);
+ return Status::InternalError<false>(
+ "Failed to resolve hostname {} and no cached ip available",
hostname);
}
std::unique_lock<std::shared_mutex> lock(mutex);
+ // _resolve_hostname may have captured a stale cached_ip before a
concurrent
+ // eviction completed. If the host is now in the negative cache we must
not
+ // reinsert the stale IP: that would silently undo the eviction and clear
the
+ // tombstone, defeating the whole purpose of eviction. Only a fresh DNS
+ // result (is_fresh == true, meaning DNS actually resolved) may override an
+ // eviction — which indicates the backend is genuinely back.
+ if (!is_fresh && _negative_cache.count(hostname)) {
+ if (out_state) {
+ *out_state = FailureState {};
+ }
+ if (out_ip) {
+ out_ip->clear();
+ }
+ // No stack trace: like the negative-cache hits in get(), this is an
expected
+ // outcome that can repeat on every request while the host stays
evicted.
+ return Status::InternalError<false>(
+ "Hostname {} was concurrently evicted; stale-fallback not
reinserted", hostname);
+ }
auto it = cache.find(hostname);
if (it == cache.end() || it->second != real_ip) {
cache[hostname] = real_ip;
LOG(INFO) << "update hostname " << hostname << "'s ip to " << real_ip;
}
+ // DNS resolved successfully — remove any negative cache tombstone so
+ // subsequent get() calls go straight to the main cache.
+ _negative_cache.erase(hostname);
+ if (out_ip) {
+ *out_ip = real_ip;
+ }
+ // Read failure_count under the same lock we already hold, so _refresh_once
+ // does not need a second lock acquisition to decide on eviction.
+ if (out_state) {
+ auto fc_it = failure_count.find(hostname);
+ *out_state = fc_it != failure_count.end() ? fc_it->second :
FailureState {};
+ }
return Status::OK();
}
+void DNSCache::_refresh_once() {
+ std::unordered_set<std::string> keys;
+ {
+ std::shared_lock<std::shared_mutex> lock(mutex);
+ std::transform(cache.begin(), cache.end(), std::inserter(keys,
keys.end()),
+ [](const auto& pair) { return pair.first; });
+ }
+ for (auto& key : keys) {
+ // Each _update() below performs a blocking getaddrinfo, so one cycle
over a
+ // cluster whose DNS is timing out can take minutes. Without this
check the
+ // destructor's join() would be held up for exactly that long, which
would
+ // undo the point of making the wait itself interruptible.
+ if (stop_refresh.load(std::memory_order_acquire)) {
+ break;
+ }
+ FailureState state;
+ Status st = _update(key, &state);
+ if (!st.ok()) {
+ // _update returns an error either when _resolve_hostname returns
""
+ // (no fallback IP) or when a stale fallback was suppressed because
+ // the host was concurrently evicted. Either way, log and move on;
+ // the threshold check below handles the normal eviction path.
+ LOG(WARNING) << "Failed to update DNS cache for hostname " << key
<< ": "
+ << st.to_string();
+ }
+ // Evict hostnames that have failed to resolve for too long.
+ // This avoids two pathological symptoms after a backend is dropped
+ // from the cluster and its DNS record is removed:
+ // 1) be.WARNING gets flooded with `failed to get ip from host`.
+ // 2) brpc keeps re-using the stale IP from cache, producing
+ // `Fail to wait EPOLLOUT ... Connection timed out`.
+ // `last_authoritative` keeps this restricted to hosts the resolver
has positively
+ // reported as non-existent; a DNS outage yields transient errors for
every host at
+ // once and must leave the cache (and the stale-IP fallback) intact.
+ int32_t threshold = config::dns_cache_max_consecutive_failures;
+ if (threshold > 0 && state.last_authoritative &&
+ state.count >= static_cast<uint32_t>(threshold)) {
+ // Re-read failure_count under the mutex that also performs the
erase
+ // to fence any concurrent success that cleared the counter between
+ // _update() returning and this point.
+ if (_erase_if_still_failing(key,
static_cast<uint32_t>(threshold))) {
+ LOG(WARNING) << "Evicting hostname " << key << " from DNS
cache after "
+ << state.count << " consecutive resolution
failures";
+ }
+ }
+ }
+}
+
void DNSCache::_refresh_cache() {
while (!stop_refresh) {
- // refresh every 1 min
- std::this_thread::sleep_for(std::chrono::minutes(1));
- std::unordered_set<std::string> keys;
{
- std::shared_lock<std::shared_mutex> lock(mutex);
- std::transform(cache.begin(), cache.end(), std::inserter(keys,
keys.end()),
- [](const auto& pair) { return pair.first; });
+ std::unique_lock<std::mutex> lk(_cv_mutex);
+ // Wake up either after 1 minute or when the destructor signals
stop.
+ _cv.wait_for(lk, std::chrono::minutes(1), [this] { return
stop_refresh.load(); });
}
- for (auto& key : keys) {
- Status st = _update(key);
- if (!st.ok()) {
- LOG(WARNING) << "Failed to update DNS cache for hostname " <<
key << ": "
- << st.to_string();
- }
+ if (!stop_refresh) {
+ _refresh_once();
}
}
}
diff --git a/be/src/util/dns_cache.h b/be/src/util/dns_cache.h
index 51ffb6567ec..4adec2f7206 100644
--- a/be/src/util/dns_cache.h
+++ b/be/src/util/dns_cache.h
@@ -17,8 +17,13 @@
#pragma once
+#include <atomic>
#include <chrono>
+#include <condition_variable>
+#include <cstdint>
+#include <functional>
#include <iostream>
+#include <mutex>
#include <shared_mutex>
#include <string>
#include <thread>
@@ -32,7 +37,28 @@ namespace doris {
// fe/fe-core/src/main/java/org/apache/doris/common/DNSCache.java
class DNSCache {
public:
+ // (hostname, out_ip, is_ipv6, out_gai_err) -> Status.
+ // out_gai_err receives the raw getaddrinfo() return code so the cache can
tell an
+ // authoritative "no such host" (EAI_NONAME) apart from a transient
resolver failure.
+ using Resolver = std::function<Status(const std::string&, std::string&,
bool, int*)>;
+
+ // Per-hostname failure bookkeeping. Only tracked for hostnames currently
present in
+ // `cache`, which keeps the map bounded (invariant: keys(failure_count) ⊆
keys(cache)).
+ struct FailureState {
+ // Consecutive resolution failures of any kind. Drives the log
throttle.
+ uint32_t count = 0;
+ // Whether the most recent failure was an authoritative NXDOMAIN.
Eviction requires
+ // this to be true, so that a DNS-server outage (which yields
EAI_AGAIN for every
+ // hostname at once) degrades to the stale cached IP instead of wiping
the cache.
+ bool last_authoritative = false;
+ };
+
DNSCache();
+
+ // Test-only constructor: uses a custom resolver and does NOT start the
+ // background refresh thread. Call refresh_for_test() to drive one cycle.
+ explicit DNSCache(Resolver resolver);
+
~DNSCache();
// get ip by hostname
@@ -42,21 +68,94 @@ private:
// Resolve hostname to IP address.
// If resolution fails, falls back to cached IP if available.
// Returns the resolved IP, or cached IP on failure, or empty string if no
cache available.
- std::string _resolve_hostname(const std::string& hostname);
+ // *is_fresh is set to true when DNS returned a live result, false when the
+ // returned IP is the stale cached fallback from a failed lookup.
+ std::string _resolve_hostname(const std::string& hostname, bool* is_fresh
= nullptr);
+
+ // update the ip of hostname in cache; out_state (if non-null) is set to
the
+ // current failure bookkeeping read under the same lock; out_ip (if
non-null)
+ // receives the resolved IP so callers can use it without a second cache
lookup
+ // (avoids operator[] mutation under shared_lock).
+ Status _update(const std::string& hostname, FailureState* out_state =
nullptr,
+ std::string* out_ip = nullptr);
+
+ // erase a hostname from cache unconditionally (with unique_lock)
+ void _erase(const std::string& hostname);
+
+ // Erase a hostname from cache only if it still meets the eviction
criteria:
+ // failure_count >= threshold AND the most recent failure was
authoritative.
+ // Re-reads the live state under the same lock that performs the erase, so
a
+ // concurrent successful resolution that cleared it is not lost.
+ // Returns true if the host was erased, false otherwise.
+ bool _erase_if_still_failing(const std::string& hostname, uint32_t
threshold);
+
+ // Drop a hostname from the cache and write a negative-cache tombstone
(subject to
+ // dns_cache_negative_ttl_seconds). Caller must already hold a unique_lock
on `mutex`.
+ void _evict_locked(const std::string& hostname);
- // update the ip of hostname in cache
- Status _update(const std::string& hostname);
+ // Record a tombstone for a hostname that could not be resolved and has no
cached IP,
+ // so repeated get() calls do not each pay a full blocking getaddrinfo.
Uses
+ // try_emplace so an entry just re-armed by get()'s single-flight path is
preserved.
+ void _remember_unresolvable(const std::string& hostname);
+
+ // one refresh cycle: update every cached hostname and evict if needed
+ void _refresh_once();
// a function for refresh daemon thread
// update cache at fix internal
void _refresh_cache();
+ // ── test helpers (accessible via friend class DNSCacheTest)
──────────────
+ size_t size_for_test() const {
+ std::shared_lock<std::shared_mutex> lock(mutex);
+ return cache.size();
+ }
+
+ size_t negative_cache_size_for_test() const {
+ std::shared_lock<std::shared_mutex> lock(mutex);
+ return _negative_cache.size();
+ }
+
+ uint32_t failure_count_for_test(const std::string& hostname) const {
+ std::shared_lock<std::shared_mutex> lock(mutex);
+ auto it = failure_count.find(hostname);
+ return it != failure_count.end() ? it->second.count : 0;
+ }
+
+ // Run one refresh cycle synchronously (no sleep). Only meaningful when
+ // the object was constructed with the test constructor (no background
thread).
+ void refresh_for_test() { _refresh_once(); }
+
+ // Backdate all negative-cache entries far into the past so they appear
+ // expired without removing them. Use this to simulate TTL expiry in tests
+ // that need the re-arm path in get() to trigger.
+ // Backdating relative to now() (rather than to steady_clock's epoch,
which is
+ // typically boot time) keeps this correct on a freshly booted host.
+ void _expire_negative_cache_for_test() {
+ std::unique_lock<std::shared_mutex> lock(mutex);
+ auto backdated = std::chrono::steady_clock::now() -
std::chrono::hours(24 * 365);
+ for (auto& [k, v] : _negative_cache) {
+ v = backdated;
+ }
+ }
+
+ friend class DNSCacheTest;
+
private:
+ Resolver _resolver; // null → use global hostname_to_ip
// hostname -> ip
std::unordered_map<std::string, std::string> cache;
+ // hostname -> consecutive resolution failure bookkeeping
+ std::unordered_map<std::string, FailureState> failure_count;
+ // hostname -> eviction timestamp; effective deadline is computed as
+ // eviction_time + dns_cache_negative_ttl_seconds to honor live config
changes.
+ std::unordered_map<std::string, std::chrono::steady_clock::time_point>
_negative_cache;
mutable std::shared_mutex mutex;
std::thread refresh_thread;
- bool stop_refresh = false;
+ // Protects stop_refresh and signals _refresh_cache to wake early on
destroy.
+ std::mutex _cv_mutex;
+ std::condition_variable _cv;
+ std::atomic<bool> stop_refresh {false};
};
} // end of namespace doris
diff --git a/be/src/util/network_util.cpp b/be/src/util/network_util.cpp
index 85e16801648..ad1315dc121 100644
--- a/be/src/util/network_util.cpp
+++ b/be/src/util/network_util.cpp
@@ -119,13 +119,13 @@ bool parse_endpoint(const std::string& endpoint,
std::string* host, uint16_t* po
return true;
}
-Status hostname_to_ip(const std::string& host, std::string& ip) {
+Status hostname_to_ip(const std::string& host, std::string& ip, int* gai_err) {
auto start = std::chrono::high_resolution_clock::now();
- Status status = hostname_to_ipv4(host, ip);
+ Status status = hostname_to_ipv4(host, ip, gai_err);
if (status.ok()) {
return status;
}
- status = hostname_to_ipv6(host, ip);
+ status = hostname_to_ipv6(host, ip, gai_err);
auto current = std::chrono::high_resolution_clock::now();
auto duration =
std::chrono::duration_cast<std::chrono::milliseconds>(current - start);
@@ -136,15 +136,15 @@ Status hostname_to_ip(const std::string& host,
std::string& ip) {
return status;
}
-Status hostname_to_ip(const std::string& host, std::string& ip, bool ipv6) {
+Status hostname_to_ip(const std::string& host, std::string& ip, bool ipv6,
int* gai_err) {
if (ipv6) {
- return hostname_to_ipv6(host, ip);
+ return hostname_to_ipv6(host, ip, gai_err);
} else {
- return hostname_to_ipv4(host, ip);
+ return hostname_to_ipv4(host, ip, gai_err);
}
}
-Status hostname_to_ipv4(const std::string& host, std::string& ip) {
+Status hostname_to_ipv4(const std::string& host, std::string& ip, int*
gai_err) {
addrinfo hints, *res;
in_addr addr;
@@ -152,10 +152,17 @@ Status hostname_to_ipv4(const std::string& host,
std::string& ip) {
hints.ai_socktype = SOCK_STREAM;
hints.ai_family = AF_INET;
int err = getaddrinfo(host.c_str(), NULL, &hints, &res);
+ if (gai_err != nullptr) {
+ *gai_err = err;
+ }
if (err != 0) {
LOG(WARNING) << "failed to get ip from host: " << host << "err:" <<
gai_strerror(err);
- return Status::InternalError("failed to get ip from host: {}, err:
{}", host,
- gai_strerror(err));
+ // No stack trace: the WARNING above already carries the host and the
resolver error,
+ // and this failure is expected often enough (a decommissioned
backend, a hostname
+ // whose DNS record has not propagated yet) that attaching a stack to
every
+ // occurrence floods be.WARNING without adding information.
+ return Status::InternalError<false>("failed to get ip from host: {},
err: {}", host,
+ gai_strerror(err));
}
addr.s_addr = ((sockaddr_in*)(res->ai_addr))->sin_addr.s_addr;
@@ -165,7 +172,7 @@ Status hostname_to_ipv4(const std::string& host,
std::string& ip) {
return Status::OK();
}
-Status hostname_to_ipv6(const std::string& host, std::string& ip) {
+Status hostname_to_ipv6(const std::string& host, std::string& ip, int*
gai_err) {
char ipstr2[128];
struct sockaddr_in6* sockaddr_ipv6;
@@ -175,10 +182,14 @@ Status hostname_to_ipv6(const std::string& host,
std::string& ip) {
hint.ai_socktype = SOCK_STREAM;
int err = getaddrinfo(host.c_str(), NULL, &hint, &answer);
+ if (gai_err != nullptr) {
+ *gai_err = err;
+ }
if (err != 0) {
LOG(WARNING) << "failed to get ip from host: " << host << "err:" <<
gai_strerror(err);
- return Status::InternalError("failed to get ip from host: {}, err:
{}", host,
- gai_strerror(err));
+ // See hostname_to_ipv4() for why this error carries no stack trace.
+ return Status::InternalError<false>("failed to get ip from host: {},
err: {}", host,
+ gai_strerror(err));
}
sockaddr_ipv6 = reinterpret_cast<struct sockaddr_in6*>(answer->ai_addr);
diff --git a/be/src/util/network_util.h b/be/src/util/network_util.h
index fe8864bd1bd..e4521c4b0a6 100644
--- a/be/src/util/network_util.h
+++ b/be/src/util/network_util.h
@@ -47,13 +47,21 @@ bool is_valid_ip(const std::string& ip);
bool parse_endpoint(const std::string& endpoint, std::string* host, uint16_t*
port);
-Status hostname_to_ip(const std::string& host, std::string& ip);
+// The `gai_err` out-parameter, when non-null, receives the raw getaddrinfo()
return code
+// (0 on success). Callers need it to tell an authoritative "no such host"
(EAI_NONAME)
+// apart from a transient resolver problem (EAI_AGAIN, EAI_SYSTEM, ...): the
former means
+// the host is really gone, the latter means DNS itself is unhealthy while the
host is
+// most likely still alive. DNSCache relies on that distinction so a resolver
outage does
+// not get mistaken for every backend disappearing at once.
+// For the two-argument hostname_to_ip(), which falls back from IPv4 to IPv6,
`gai_err`
+// reports the code of the last attempt.
+Status hostname_to_ip(const std::string& host, std::string& ip, int* gai_err =
nullptr);
-Status hostname_to_ipv4(const std::string& host, std::string& ip);
+Status hostname_to_ipv4(const std::string& host, std::string& ip, int* gai_err
= nullptr);
-Status hostname_to_ipv6(const std::string& host, std::string& ip);
+Status hostname_to_ipv6(const std::string& host, std::string& ip, int* gai_err
= nullptr);
-Status hostname_to_ip(const std::string& host, std::string& ip, bool ipv6);
+Status hostname_to_ip(const std::string& host, std::string& ip, bool ipv6,
int* gai_err = nullptr);
// Finds the first non-localhost IP address in the given list. Returns
// true if such an address was found, false otherwise.
diff --git a/be/test/util/dns_cache_test.cpp b/be/test/util/dns_cache_test.cpp
new file mode 100644
index 00000000000..dab8bbdead1
--- /dev/null
+++ b/be/test/util/dns_cache_test.cpp
@@ -0,0 +1,819 @@
+// 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
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]