~/systems/dns-resolver

DNS resolver

Follow aliases to an answer, detect loops with a seen set, and write each result back onto the whole chain so every name is looked up once.

what

Walk from name to name with a seen set; stop at an answer, a missing name or a repeat. Then store the result for every name on the chain, so later lookups that touch any of them finish at once.

use when

CNAME chains, redirects, symlinks, forwarding addresses, "resolve every alias", plus the real-world extras: case-insensitive names, TTL caches, wildcards and many threads asking at once.

time

O(n) for all lookups together

space

O(n)

You’ll recognise it when

  • Each name either has an answer or points to another name: CNAMEs, URL redirects, symlinks, mail forwarding.
  • A chain can end at nothing (a missing record) or loop back on itself.
  • You resolve many names, and their chains overlap.
  • Follow-ups add real DNS details: names that differ only in case or a trailing dot, answers that expire, wildcard records, many threads asking for the same name.

It’s often confused with general graph search. Each name points to at most one next name, so the graph is a set of chains that may end in a cycle (a functional graph). No BFS is needed: just follow the arrows, with the cycle detection idea of remembering where you’ve been.

The idea

You’re delivering a parcel, and the note on the door says “moved, see number 12”. Number 12 says “see number 30”. You keep a list of the doors you’ve knocked on: if a note sends you back to one of them, you’re going in circles and give up. When you finally find someone, you write the real address on every door you passed, so the next courier with a parcel for any of them goes straight there.

The resolver does the same. It follows records one name at a time, keeping the names it has visited in seen and in order in chain. It stops at an address, at a name with no record, or at a name already in seen (a loop). Then it writes the result into cache for every name on the chain. A later query that reaches any of those names stops right there.

How it works

records maps a name to ("A", ip) or ("CNAME", target). Names are compared after normalizing: lower case, no trailing dot.

  1. Normalize the queried name, then start an empty chain and seen.
  2. Known already? If the current name is in cache, its answer is the answer for the whole chain so far. Stop.
  3. A loop? If it’s in seen, the chain came back to a name it already passed: the answer is LOOP. Stop.
  4. Read the record. Add the name to seen and chain. No record means NXDOMAIN. An A record is the answer. A CNAME gives the next name to visit, normalized.
  5. Write back. Store the answer in cache for every name in chain. Names that lead into a loop get LOOP too, which is right: resolving them would go round forever.

With these records and queries:

query walk answer new store reads
m.shop.com m → WWW.Shop.com. (normalized) → shop.com 10.0.0.7 3
www.shop.com in cache 10.0.0.7 0
old.shop.com → gone.shop.com, which has no record NXDOMAIN 2
x.loop → a.loop → b.loop → c.loop → a.loop again LOOP 4
b.loop in cache LOOP 0

Why it’s correct: a name’s answer depends only on where its chain leads, so every name on one walk shares the walk’s answer. A chain that revisits a name will repeat forever, so stopping at the first repeat is safe, and any name that reaches a cached name shares that name’s answer.

def normalize(name):
return name.lower().rstrip(".") # "WWW.Shop.com." -> "www.shop.com"
class Resolver:
def __init__(self, records):
self.records = records # name -> ("A", ip) or ("CNAME", target)
self.cache = {} # name -> ip, "NXDOMAIN" or "LOOP"
self.lookups = 0 # how many times we read the record store
def resolve(self, name):
name = normalize(name)
chain, seen = [], set()
while name not in self.cache:
if name in seen: # back to a name on this chain: a cycle
result = "LOOP"
break
seen.add(name)
chain.append(name)
self.lookups += 1
record = self.records.get(name)
if record is None:
result = "NXDOMAIN" # the chain ends at a name with no record
break
kind, value = record
if kind == "A":
result = value
break
name = normalize(value) # a CNAME: follow it
else:
result = self.cache[name] # reached a name we already know
for visited in chain: # every name on the chain has the same answer
self.cache[visited] = result
return result
#include <algorithm>
#include <cctype>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
string normalize(string name) { // "WWW.Shop.com." -> "www.shop.com"
for (char& c : name) c = tolower((unsigned char)c);
while (!name.empty() && name.back() == '.') name.pop_back();
return name;
}
class Resolver {
unordered_map<string, pair<string, string>> records; // name -> {"A", ip} or {"CNAME", target}
unordered_map<string, string> cache; // name -> ip, "NXDOMAIN" or "LOOP"
public:
int lookups = 0; // how many times we read the record store
explicit Resolver(unordered_map<string, pair<string, string>> records) : records(move(records)) {}
string resolve(string name) {
name = normalize(name);
vector<string> chain;
unordered_set<string> seen;
string result;
while (true) {
auto hit = cache.find(name);
if (hit != cache.end()) { result = hit->second; break; } // a name we already know
if (seen.count(name)) { result = "LOOP"; break; } // back on this chain: a cycle
seen.insert(name);
chain.push_back(name);
lookups++;
auto rec = records.find(name);
if (rec == records.end()) { result = "NXDOMAIN"; break; } // the chain ends at nothing
auto& [kind, value] = rec->second;
if (kind == "A") { result = value; break; }
name = normalize(value); // a CNAME: follow it
}
for (auto& visited : chain) cache[visited] = result; // the whole chain shares the answer
return result;
}
};
import java.util.*;
class Resolver {
private final Map<String, String[]> records; // name -> {"A", ip} or {"CNAME", target}
private final Map<String, String> cache = new HashMap<>(); // name -> ip, "NXDOMAIN" or "LOOP"
int lookups = 0; // how many times we read the record store
Resolver(Map<String, String[]> records) { this.records = records; }
static String normalize(String name) { // "WWW.Shop.com." -> "www.shop.com"
String n = name.toLowerCase(Locale.ROOT);
while (n.endsWith(".")) n = n.substring(0, n.length() - 1);
return n;
}
String resolve(String name) {
name = normalize(name);
List<String> chain = new ArrayList<>();
Set<String> seen = new HashSet<>();
String result;
while (true) {
if (cache.containsKey(name)) { result = cache.get(name); break; } // a name we already know
if (!seen.add(name)) { result = "LOOP"; break; } // back on this chain: a cycle
chain.add(name);
lookups++;
String[] record = records.get(name);
if (record == null) { result = "NXDOMAIN"; break; } // the chain ends at nothing
if (record[0].equals("A")) { result = record[1]; break; }
name = normalize(record[1]); // a CNAME: follow it
}
for (String visited : chain) cache.put(visited, result); // the whole chain shares the answer
return result;
}
}

Caching with TTLs

Real records come with a time to live. Cache each answer with expires_at = now + ttl, where the TTL of an answer reached through several CNAMEs is the smallest TTL along the chain: the answer is only as fresh as its most short-lived link. On a read, a stale entry counts as a miss. Take the clock as a parameter so tests can move time without sleeping.

Many threads, one lookup

When a hundred threads ask for the same uncached name at once, they shouldn’t all hit the upstream server. Coalesce: the first thread starts the lookup and records it as in flight (for example, a future or an event per name); the others wait for that result and share it, including a failure. Never hold one global lock while talking to the slow store, or a single slow name blocks every other lookup.

Why it’s O(n)

Every name is read from the record store at most once across all queries: after its first walk, it’s in cache. A walk costs one step per new name plus one final step (a cached name, a missing one or a repeat), so the total over q queries on n names is O(n + q). A single query on its own is O(length of its chain). The cache and the walk’s lists take O(n) space.

Without the write-back, resolving every name in a long chain is O(n²): each of the n names walks to the end again.

Common mistakes

No loop detection

A CNAME cycle makes a plain “follow until you find an answer” loop forever. Keep the names you’ve seen on this walk.

while kind == "CNAME": name = target # ✗ never ends on a cycle
if name in seen: return "LOOP" # ✓ the first repeat stops it

Caching only the name that was asked

Storing the answer for the queried name alone leaves the rest of the chain uncached, so overlapping queries re-walk it. Write the answer onto every name you passed.

self.cache[query] = result # ✗ the chain is walked again later
for visited in chain: self.cache[visited] = result # ✓ each name resolved once

Comparing names before normalizing

WWW.Shop.com. and www.shop.com are the same name. Comparing raw strings misses cache hits and, worse, misses loops that come back in a different spelling.

seen.add(target) # ✗ "A.loop." isn't "a.loop"
seen.add(normalize(target)) # ✓ one spelling per name

A seen set shared across queries

A set kept for the whole resolver instead of per walk flags a name visited by an earlier query as a loop. The cache handles names from earlier walks; seen is only for this one.

self.seen.add(name) # ✗ earlier walks look like loops
seen = set() # ✓ fresh for every walk

Variations

  • Wildcards. A record for *.shops.example answers any name ending in .shops.example with at least one label in front. The most specific record wins: try the exact name first, then wildcards from the longest suffix to the shortest.
  • Fallbacks. When a name fails, try a configured fallback name, which may have its own fallback. Guard that chain with a seen set too.
  • Upstream errors. A store that times out isn’t the same as a missing name. Raise a different error, and don’t cache failures for as long as answers (or at all).
  • Hop limits. Real resolvers also stop after a fixed number of hops, even without a loop, to bound the work a hostile zone can cause.
  • Symlinks and redirects. The same walk resolves a file path through symlinks or a URL through redirects; only the record lookup changes.

Climb the ladder

Our DNS resolver problems in ladder order.

  1. Follow a CNAME chain with loop detection: the walk with a seen set.
  2. Exact and wildcard records, most specific wins: matching by longest suffix.
  3. DNS resolution with CNAME cycles: the five-level OA, from CNAMEs to fallbacks, TTL caches and coalesced lookups across threads.

Check yourself

5 quick questions. Pick an answer to see why it's right or wrong.

  1. 1

    How many times does this resolver read records?

    records = {"a": "b", "b": "c", "c": "1.2.3.4"} # next name, or an address
    cache, reads = {}, 0
    def resolve(name):
    global reads
    chain = []
    while name not in cache:
    reads += 1
    chain.append(name)
    target = records[name]
    if target[0].isdigit():
    for visited in chain:
    cache[visited] = target
    return target
    name = target
    return cache[name]
    resolve("a"); resolve("b"); resolve("c")
    print(reads)
  2. 2

    The records are a → b, b → c and c → b, all CNAMEs. What should resolving a return?

  3. 3

    www is a CNAME with TTL 300 to cdn, a CNAME with TTL 60 to edge, which has an A record with TTL 3600. For how many seconds may the resolved address of www be cached?

  4. 4

    A hundred threads ask a caching resolver for the same uncached name at the same moment. What should happen?

  5. 5

    Records: *.example.com → A, *.eu.example.com → B, shop.eu.example.com → C. A wildcard covers any name with at least one label in front of its suffix, and the most specific record wins. What answers a.b.eu.example.com?

Practice problems

Solve these right here, in Python, C++ or Java. Tests run as you go.

Further reading

esc