blob: 9a4883bc3997225297f841a20ff798469ecd711e [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 <netdb.h>
#include <algorithm>
#include <atomic>
#include <unordered_set>
#include "common/config.h"
#include "service/backend_options.h"
#include "util/network_util.h"
namespace doris {
DNSCache::DNSCache() {
refresh_thread = std::thread(&DNSCache::_refresh_cache, this);
}
DNSCache::DNSCache(Resolver resolver) : _resolver(std::move(resolver)) {}
DNSCache::~DNSCache() {
{
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);
if (it != cache.end()) {
*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;
}
}
// 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.
// *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;
{
std::shared_lock<std::shared_mutex> lock(mutex);
auto it = cache.find(hostname);
if (it != cache.end()) {
cached_ip = it->second;
}
}
// Try to resolve hostname
std::string resolved_ip;
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()) {
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()) {
// 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 {
// 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;
}
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()) {
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) {
{
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(); });
}
if (!stop_refresh) {
_refresh_once();
}
}
}
} // end of namespace doris