Thuta Learning
ExercisesDevOps & Toolsbeginner

Exercise: Build a DNS Resolver Cache

What you'll walk away with

  • Explain the core ideas behind Exercise: Build a DNS Resolver Cache
  • Read the diagram and trace how data actually moves through it
  • Run the sample code and verify its output

Build the mental model

DNS does not work because the root servers are fast. It works because almost nobody asks them anything. There are thirteen root name server addresses for the entire internet, and if every browser tab resolved every hostname from the root downward, the system would have collapsed decades ago. Caching is not an optimisation bolted onto DNS afterwards -- it is the load-shedding mechanism that makes a hierarchy this shallow survivable at all. A resolver answering a million queries a day out of a few thousand cached records is doing exactly what the protocol was designed around.

The thing that makes reuse safe is the TTL, and the TTL is easy to misread. It is not a countdown the server maintains on your behalf, and it is not a promise that the record will change when it expires. It is a one-way contract: the zone owner is telling you "you may reuse this answer for the next N seconds, and I accept that I have no way to reach you and correct it during that window." That is why lowering a TTL is the first step before any planned migration. You are shrinking the blast radius of your own staleness before you go and create some.

Because nothing can push an update to you, the expiry check has to happen when you read, not on a timer. A cache that only sweeps expired entries in a background pass will serve a stale address in the gap between sweeps. Store an absolute expires_at at insert time, then compare it against the current time on every single lookup. Read-time expiry is what turns a dictionary into a cache.

text
DNS CACHE LOOKUP PATH
---------------------
             lookup(name, now)
                    |
                    v
          +---------------------+
          | name in cache?      |
          +----+-----------+----+
            no |           | yes
               |           v
               |   +---------------------+
               |   | now < expires_at ?  |  <-- TODO (b)
               |   +----+-----------+----+
               |    yes |           | no
               |        v           v
               |    +-------+   +----------+
               |    | HIT   |   |  evict   |
               |    +-------+   |  entry   |
               |     TODO (a)   +----+-----+
               |                     |
               +----------+----------+
                          v
              +-------------------------+
              | fetch_from_zone(name)   |   zone_queries += 1
              +-------------------------+
                          |
                          v
              +-------------------------+
              | cache[name] =           |
              |   (addr, now + ttl)     |   store ABSOLUTE expiry
              +-------------------------+
                          |
                          v
                        MISS

Connect it to a real scenario

The scaffold below is a resolver with its plumbing already in place and its two interesting decisions left out. ZONE is the authoritative data: three names, each with an address and a TTL of 60, 30 and 300 seconds. cache maps a name to a tuple of (address, expires_at). zone_queries counts every trip to the authoritative server, which is the number you are ultimately trying to drive down.

Time never comes from the clock. Every lookup takes an explicit now in seconds, and TRACE is a fixed script of six queries at t = 0, 10, 12, 75, 80 and 100. That is what makes the exercise checkable: the same input always produces the same trace, so you can diff your output against the expected one instead of squinting at wall-clock timings that shift on every run.

lookup() is already finished for the cache-miss path. It fetches from the zone, stores the address with an absolute expiry of now + ttl, and reports a miss. The branch where an entry already exists is deliberately hollow. Rather than quietly returning something plausible, it reports MISS (cache hit not implemented) so the trace tells you exactly which decisions are still missing. Run it once before you write anything and read that status column top to bottom.

Try the working example

python
"""DNS resolver cache -- STARTER SCAFFOLD.

The clock is passed in as an explicit integer `now` (seconds since the
trace started) so that every run gives the same result. Two pieces are
left for you to write: the cache-hit path and the TTL expiry check.
"""

# The "authoritative" zone: name -> (address, ttl in seconds)
ZONE = {
    "www.example.com": ("93.184.216.34", 60),
    "api.example.com": ("93.184.216.35", 30),
    "cdn.example.com": ("93.184.216.36", 300),
}

# name -> (address, expires_at)
cache = {}

zone_queries = 0


def fetch_from_zone(name):
    """Stand-in for asking the authoritative server. Counts the traffic."""
    global zone_queries
    zone_queries += 1
    return ZONE[name]


def lookup(name, now):
    """Resolve `name` at time `now`. Returns (status, address)."""
    entry = cache.get(name)

    if entry is not None:
        # TODO (b): if now >= entry[1] the entry has expired -- evict it
        #           from the cache and fall through to the fetch below.
        # TODO (a): otherwise this is a HIT: return the cached address
        #           without touching the zone table at all.
        return ("MISS (cache hit not implemented)", entry[0])

    address, ttl = fetch_from_zone(name)
    cache[name] = (address, now + ttl)
    return ("MISS (fetched from zone)", address)


TRACE = [
    ("www.example.com", 0),
    ("www.example.com", 10),
    ("api.example.com", 12),
    ("www.example.com", 75),
    ("api.example.com", 80),
    ("cdn.example.com", 100),
]

print("{:<5} {:<18} {:<34} {}".format("t", "name", "status", "address"))
print("-" * 74)
for query_name, query_time in TRACE:
    status, address = lookup(query_name, query_time)
    print("{:<5} {:<18} {:<34} {}".format(query_time, query_name, status, address))

print("-" * 74)
print("zone queries: {}".format(zone_queries))
print("cached names: {}".format(len(cache)))
You should see
t     name               status                             address
--------------------------------------------------------------------------
0     www.example.com    MISS (fetched from zone)           93.184.216.34
10    www.example.com    MISS (cache hit not implemented)   93.184.216.34
12    api.example.com    MISS (fetched from zone)           93.184.216.35
75    www.example.com    MISS (cache hit not implemented)   93.184.216.34
80    api.example.com    MISS (cache hit not implemented)   93.184.216.35
100   cdn.example.com    MISS (fetched from zone)           93.184.216.36
--------------------------------------------------------------------------
zone queries: 3
cached names: 3

5-minute try-it

Fill in the two TODOs so that lookup() behaves like a real resolver cache.

The hit: if the name is cached and now is strictly less than its expires_at, return the cached address with status HIT and do not call fetch_from_zone at all. zone_queries must not move.

The expiry: if the name is cached but now has reached or passed expires_at, delete the entry, re-fetch it from the zone, and store it again with a fresh expires_at of now + ttl. Report that as a miss -- a refresh is a miss, because it cost you a query.

Against the supplied TRACE, a correct implementation produces: t=0 miss for www; t=10 HIT for www, since its 60-second TTL is still valid; t=12 miss for api; t=75 miss for www, because that TTL expired at t=60 and the entry had to be refreshed; t=80 miss for api, whose 30-second TTL expired back at t=42; t=100 miss for cdn. Final counts: zone queries 5, cached names 3.

Then extend the trace yourself: query www.example.com again at t=80 and confirm you get a HIT off the entry that was refreshed at t=75, with zone queries still sitting at 5.

One important caution

Storing the TTL itself instead of an absolute expires_at. A stored TTL has to be decremented by somebody, and the moment two code paths disagree about who does it, entries either live forever or vanish immediately. Compute now + ttl once at insert time.

Caching negative answers (NXDOMAIN) with the positive record's TTL, or not caching them at all. A name that does not exist gets hammered by retries; RFC 2308 gives negative answers their own, usually much shorter, TTL for exactly this reason.

RFC 1035 - Domain Names: Implementation and SpecificationComputer Networking

Easy traps

  • Storing the TTL itself instead of an absolute expires_at. A stored TTL has to be decremented by somebody, and the moment two code paths disagree about who does it, entries either live forever or vanish immediately. Compute now + ttl once at insert time.
  • Caching negative answers (NXDOMAIN) with the positive record's TTL, or not caching them at all. A name that does not exist gets hammered by retries; RFC 2308 gives negative answers their own, usually much shorter, TTL for exactly this reason.
  • Validate sample code in a local or test environment before applying it to a production network.

Exercise

Fill in the two TODOs so that lookup() behaves like a real resolver cache.

The hit: if the name is cached and now is strictly less than its expires_at, return the cached address with status HIT and do not call fetch_from_zone at all. zone_queries must not move.

The expiry: if the name is cached but now has reached or passed expires_at, delete the entry, re-fetch it from the zone, and store it again with a fresh expires_at of now + ttl. Report that as a miss -- a refresh is a miss, because it cost you a query.

Against the supplied TRACE, a correct implementation produces: t=0 miss for www; t=10 HIT for www, since its 60-second TTL is still valid; t=12 miss for api; t=75 miss for www, because that TTL expired at t=60 and the entry had to be refreshed; t=80 miss for api, whose 30-second TTL expired back at t=42; t=100 miss for cdn. Final counts: zone queries 5, cached names 3.

Then extend the trace yourself: query www.example.com again at t=80 and confirm you get a HIT off the entry that was refreshed at t=75, with zone queries still sitting at 5.

You'll know it worked when: t name status address -------------------------------------------------------------------------- 0 www.example.com MISS (fetched from zone) 93.184.216.34 10 www.example.com MISS (cache hit not implemented) 93.184.216.34 12 api.example.com MISS (fetched from zone) 93.184.216.35 75 www.example.com MISS (cache hit not implemented) 93.184.216.34 80 api.example.com MISS (cache hit not implemented) 93.184.216.35 100 cdn.example.com MISS (fetched from zone) 93.184.216.36 -------------------------------------------------------------------------- zone queries: 3 cached names: 3

Exercise: Build a DNS Resolver Cache | Thuta Learning