~/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.
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.
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.
O(n) for all lookups together
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.
- Normalize the queried name, then start an empty
chainandseen. - Known already? If the current name is in
cache, its answer is the answer for the whole chain so far. Stop. - A loop? If it’s in
seen, the chain came back to a name it already passed: the answer isLOOP. Stop. - Read the record. Add the name to
seenandchain. No record meansNXDOMAIN. AnArecord is the answer. ACNAMEgives the next name to visit, normalized. - Write back. Store the answer in
cachefor every name inchain. Names that lead into a loop getLOOPtoo, 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.exampleanswers any name ending in.shops.examplewith 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.
- Follow a CNAME chain with loop detection: the walk with a seen set.
- Exact and wildcard records, most specific wins: matching by longest suffix.
- 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
How many times does this resolver read
records?records = {"a": "b", "b": "c", "c": "1.2.3.4"} # next name, or an addresscache, reads = {}, 0def resolve(name):global readschain = []while name not in cache:reads += 1chain.append(name)target = records[name]if target[0].isdigit():for visited in chain:cache[visited] = targetreturn targetname = targetreturn cache[name]resolve("a"); resolve("b"); resolve("c")print(reads)Resolving
areadsa,bandc, then writes the address onto all three names. The next two queries find their names in the cache and read nothing. Caching only the queried name would cost 3 + 2 + 1 = 6 reads, and on a chain of n names that grows to O(n²). -
2
The records are
a → b,b → candc → b, all CNAMEs. What should resolvingareturn?aisn’t on the cycle itself, but following it reachesb,c, thenbagain, and would go round forever. Soagets the same answer as the names in the cycle. NXDOMAIN is for chains that end at a name with no record, which never happens here. -
3
wwwis a CNAME with TTL 300 tocdn, a CNAME with TTL 60 toedge, which has an A record with TTL 3600. For how many seconds may the resolved address ofwwwbe cached?The answer depends on every record on the chain, and the
cdnalias may change after 60 seconds. So the cached answer is only good for the smallest TTL along the way. Using the final record’s 3600 would keep serving the old address long aftercdnwas repointed. -
4
A hundred threads ask a caching resolver for the same uncached name at the same moment. What should happen?
Coalescing sends one upstream request per name and gives every waiting thread the same result, or the same error. Letting all of them ask multiplies the load at the worst moment. A global lock around the upstream call makes one slow name block lookups of every other name.
-
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 answersa.b.eu.example.com?There’s no exact record for the name. Both wildcards cover it, and
*.eu.example.comhas the longer suffix, so it’s more specific: B. The exact record forshop.eu.example.comis a different name, so it doesn’t apply.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.