cache

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package cache stores DNS responses and serves them until they expire.

A cache is the difference between a resolver that is useful and one that is not. It is also the component where a plausible-looking shortcut turns into a correctness bug that only shows up as somebody else's outage, so the rules it implements are spelled out here rather than left to the reader.

What is cached

The unit is a whole response, keyed by the question — name, type and class — and not by individual records. DNS answers are only meaningful together: a CNAME chain, the SOA that authorises a negative answer, and the additional records that make a referral usable all lose their meaning if split apart and re-assembled. Storing the message keeps the semantics the upstream intended.

A name error is the one exception, and RFC 2308 section 5 is the reason; see "Negative answers" below.

Names are keyed in their canonical, lower-cased form so that lookups are case-insensitive without paying for a case-insensitive comparison at every level (RFC 4343). Record owner names keep the case they arrived in, which RFC 4343 makes immaterial to a client.

The question section is different: it comes back carrying the spelling the CALLER used, not the one stored. RFC 5452 section 4.3 has a stub resolver discard any reply whose question does not match its query, and the stored spelling belongs to whoever filled the entry — which for a resolver using DNS-0x20 is a randomised query it sent upstream. Returning that to a later client would make the client throw the answer away. DNS-0x20 belongs to the resolver-to-upstream leg, where the echo is verified before anything reaches this package.

TTL is a countdown, not a timestamp

RFC 2181 section 8 is explicit that a TTL is the number of seconds a record may be used, counted from when it was received. A cache that returns a record with its original TTL is telling every downstream resolver to hold it for the full period again, which turns a five-minute record into an indefinite one across a chain of caches. Cache.Get therefore returns a copy with every TTL decremented by the time the entry has been held, and an entry whose smallest TTL has reached zero is expired.

The whole message shares one expiry, taken from the smallest TTL in the answer, authority and additional sections. Using the largest, or per-record expiry, would let a message be served with some records live and some stale.

Negative answers

NXDOMAIN and NODATA are cached, because not doing so means every typo and every probe for a nonexistent name becomes an upstream query. RFC 2308 governs how long: the lifetime is the smaller of the SOA record's TTL and its MINIMUM field, taken from the authority section. A negative answer carrying no SOA is refused outright rather than given a lifetime of our own invention, because inventing one would let a misbehaving upstream suppress a name for as long as we chose to believe it.

The two denials are not stored alike. RFC 2308 section 5 says a NODATA answers another query for the same name, type and class, while a name error answers another query for the same name and class — no type, because a name that does not exist does not exist for anything. So a NODATA is kept under the type it denies and a name error under the name alone, and one stored NXDOMAIN answers A, AAAA and HTTPS alike. That asymmetry is worth the extra probe every miss pays for it: a dual-stack client asks two or three questions about every typo, and type-scoped denials would spend an upstream query on each of them to learn the same nothing. The saving is on exactly the traffic — typos, random subdomain floods — that makes negative caching worth having.

The lookup that reuses a denial across types checks the stored RCODE rather than trusting where the entry was filed, so a NODATA can never answer for a type the zone really publishes. The reused answer goes out with the question section restated as the question that was asked, because a stub resolver discards a reply whose question does not match its query (RFC 5452 section 4.3) and a denial the client throws away would have cost a query rather than saved one.

Serving stale

RFC 8767 permits a resolver to answer from an expired entry when the upstream cannot be reached. This turns an upstream outage from a total failure into slightly out-of-date answers, which is almost always what a user would choose. It is off unless configured, because it is a deliberate correctness trade, and a stale answer always leaves with a TTL of at most StaleTTL.

"When the upstream cannot be reached" is the caller's judgement, not the cache's. RFC 8767 section 5 permits stale data only where fresh data cannot be had, and this package holds no sockets and speaks to no upstream, so it cannot make that test. Cache.Get returns the stale message and labels it StatusStale; the caller, which has just tried the network, decides whether to send it. Hits inside the stale window are offered to any registered refresher, so the window closes on its own once the upstream recovers rather than depending on the caller's diligence.

A stale answer also carries an EDNS Extended DNS Error saying what it is — but only when the stored response already carried an OPT record. RFC 6891 section 6.1.1 does not let a responder put an OPT record in a reply to a query that had none, and a cached response that arrived without EDNS came from an exchange that had none, so manufacturing one would hand the caller a malformed reply for a plain DNS client. Result.Status tells the caller the same fact by a route that cannot corrupt the message.

What is never cached

Responses to meta queries (ANY, AXFR, IXFR), messages with the TC bit set, SERVFAIL and REFUSED, responses carrying no question, and any message whose smallest TTL is zero. RFC 2181 section 8 makes a zero TTL mean "use this once and do not store it", and honouring that matters for the failover and load-balancing schemes that depend on it.

Also refused: a response to any opcode other than QUERY, which is not a fact about a name that can be replayed to a later querent; and a response carrying an EDNS Client Subnet option with a non-zero scope, which is an answer for one client's network rather than for everyone. Storing the latter under a key that does not include the subnet would serve one user's geo-located answer to another. Every refusal is counted under its own Reason, so a low hit rate can be diagnosed from outside rather than guessed at.

Concurrency and cost

Cache is safe for concurrent use and is built for a server handling tens of thousands of queries a second. Entries are spread across independently locked shards so that concurrent lookups of unrelated names do not contend, and each shard evicts its own least recently used entry when it is full. A hit copies the stored message, because the caller receives something it may modify and the cache must not hand the same memory to two goroutines.

Expired entries are removed lazily on lookup and by an optional background sweep. Lazy removal alone is not enough: a name queried once and never again would otherwise occupy its slot until eviction pressure reached it.

Index

Examples

Constants

View Source
const DefaultMaxEntries = 10000

DefaultMaxEntries is the entry ceiling used when Options.MaxEntries is zero.

Ten thousand entries suits the deployment this engine is built for: a household or a small office, whose working set of distinct names is a few thousand and whose tail is mostly one-shot lookups that expire before anybody asks again. At the few hundred bytes an average response occupies it costs single-digit megabytes, which is a defensible amount to take from a machine that did not ask to be a resolver. A recursive server fronting a large network should raise it; nothing here assumes the default is right.

View Source
const DefaultPrefetchThreshold = 0.10

DefaultPrefetchThreshold is the fraction of an entry's lifetime that must remain before a hit offers it to the refresher, used when Options.PrefetchThreshold is zero.

A tenth is late enough that the overwhelming majority of entries are never refreshed — a name asked for in the last tenth of its life is, by that fact, one somebody keeps asking for — and early enough that a refresh has room to finish before anybody misses. Refreshing much earlier turns the cache into a scheduled re-resolver of everything anyone has ever asked for, which is the failure mode prefetching is usually blamed for.

View Source
const MaxShards = 4096

MaxShards is the largest number of shards a cache may be built with.

Every shard costs a map header, a pair of list pointers and a cache line of padding whether or not it ever holds an entry, and a shard count far above the core count buys no further contention relief — it only spreads the entries so thin that each shard's share of the entry budget rounds to noise. Anything above this is a configuration mistake rather than a tuning choice.

View Source
const StaleTTL uint32 = 30

StaleTTL is the TTL placed on a response served from an expired entry under RFC 8767.

Thirty seconds is the value RFC 8767 section 4 recommends. It has to be short: a downstream resolver must re-ask soon after the upstream recovers, and it must not propagate a stale answer as though it were fresh. It must also not be zero, or every client behind a shared forwarder would stampede the moment the upstream came back.

Variables

View Source
var ErrClosed = errors.New("cache: closed")

ErrClosed reports that the cache has been closed.

A closed cache does not restart. Shutdown in a real server is never as tidy as intended, and a cache that could be revived after Close would turn a late call from a draining listener into a resurrected goroutine nobody is waiting on.

View Source
var ErrSweepInterval = errors.New("cache: sweep interval must be positive")

ErrSweepInterval reports a non-positive sweep interval, which no ticker can express. It is rejected rather than defaulted so that a configuration mistake surfaces at startup instead of as a cache that silently never sweeps.

View Source
var ErrSweeperRunning = errors.New("cache: sweeper already running")

ErrSweeperRunning reports an attempt to start a second sweeper on one cache.

It is an error rather than a silent no-op because the second caller has supplied an interval and a context that would be quietly ignored, and would reasonably believe its own cancellation stopped the sweep.

Functions

This section is empty.

Types

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache is a sharded, LRU-evicting store of DNS responses.

It is safe for concurrent use by any number of goroutines. Entries are spread across independently locked shards chosen by a hash of the Key, so that lookups of unrelated names do not contend, and each shard evicts its own least recently used entry when it exceeds its share of the budget.

What the bounds mean, and what is approximate about them

Options.MaxEntries and Options.MaxBytes are ceilings on the whole cache and the cache does not exceed them. Each shard is given a share; the remainder of the division goes to the lowest-numbered shards so that the shares sum to exactly the configured budget, and a shard makes room before it admits an entry rather than after, so no reader of Cache.Len ever sees a count above the budget either.

What is approximate is WHICH entries survive. Eviction is per shard and least-recently-used within it, so a shard whose names are all busy evicts a live entry while a quiet shard holds an idle one: the cache stays inside the budget by discarding something a global LRU would have kept. Names do not distribute perfectly, and the effect grows as the number of entries approaches the number of shards — a hundred entries across sixty-four shards is mostly rounding. This is the price of not taking a global lock on every hit, since a global LRU reorders a shared list on every hit, and it is stated plainly rather than implied away.

A cache configured for fewer entries than it would have shards is given fewer shards instead, because a shard permitted no entries could only refuse what hashed to it.

What a lookup costs

A lookup hashes the key, takes one shard's mutex, looks the key up, moves the node to the front of that shard's list and releases the mutex. The message copy — unavoidable, since the caller may modify what it receives — happens after the mutex is released, so duplicating a large response never blocks lookups of unrelated names.

Example

ExampleCache shows the countdown RFC 2181 section 8 requires. A response is stored once with the TTL it arrived with, and every hit returns a copy whose TTL says how much of that lifetime is left. A cache that returned 300 each time would be telling the next resolver along to hold the record for five more minutes, on every lookup, for ever.

package main

import (
	"fmt"
	"log"
	"net/netip"
	"time"

	"github.com/daboss2003/dns/cache"
	"github.com/daboss2003/dns/clock"
	"github.com/daboss2003/dns/dnsmsg"
)

// exampleEpoch is the instant the examples start from. A recognisable, non-zero
// wall time keeps the expected output readable and avoids the year-1 underflow
// [clock.NewFake] warns about.
var exampleEpoch = time.Date(2024, time.March, 1, 12, 0, 0, 0, time.UTC)

// exampleQuestion is the question every example asks.
var exampleQuestion = dnsmsg.Question{
	Name:  dnsmsg.MustParseName("example.com."),
	Type:  dnsmsg.TypeA,
	Class: dnsmsg.ClassINET,
}

// exampleAnswer builds the response an upstream would have returned: one A
// record with the given TTL.
func exampleAnswer(ttl uint32) *dnsmsg.Message {
	return &dnsmsg.Message{
		Header:    dnsmsg.Header{Response: true, RecursionAvailable: true},
		Questions: []dnsmsg.Question{exampleQuestion},
		Answers: []dnsmsg.RR{{
			Name:  exampleQuestion.Name,
			Type:  dnsmsg.TypeA,
			Class: dnsmsg.ClassINET,
			TTL:   ttl,
			Data:  &dnsmsg.A{Addr: netip.MustParseAddr("192.0.2.1")},
		}},
	}
}

func main() {
	clk := clock.NewFake(exampleEpoch)
	c, err := cache.New(cache.Options{Clock: clk})
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	c.Put(exampleAnswer(300))

	for _, elapsed := range []time.Duration{0, 2 * time.Minute, 5 * time.Minute} {
		clk.Set(exampleEpoch.Add(elapsed))

		m, res := c.Get(exampleQuestion, false)
		switch res.Status {
		case cache.StatusHit:
			fmt.Printf("%3.0fs: %s, ttl %d\n", elapsed.Seconds(), res.Status, m.Answers[0].TTL)
		default:
			fmt.Printf("%3.0fs: %s\n", elapsed.Seconds(), res.Status)
		}
	}

}
Output:
  0s: hit, ttl 300
120s: hit, ttl 180
300s: expired
Example (ServeStale)

ExampleCache_serveStale shows RFC 8767. Serving stale is off unless configured, because it is a deliberate correctness trade; once it is on, an entry past its TTL still answers when nothing better is available, which turns an upstream outage into slightly out-of-date answers rather than a total failure.

What comes back says exactly that. The TTL is cache.StaleTTL so that downstream resolvers re-ask promptly, and an EDNS Extended DNS Error of "Stale Answer" (RFC 8914 code 3) means nobody is misled about what they received.

package main

import (
	"fmt"
	"log"
	"net/netip"
	"time"

	"github.com/daboss2003/dns/cache"
	"github.com/daboss2003/dns/clock"
	"github.com/daboss2003/dns/dnsmsg"
)

// exampleEpoch is the instant the examples start from. A recognisable, non-zero
// wall time keeps the expected output readable and avoids the year-1 underflow
// [clock.NewFake] warns about.
var exampleEpoch = time.Date(2024, time.March, 1, 12, 0, 0, 0, time.UTC)

// exampleQuestion is the question every example asks.
var exampleQuestion = dnsmsg.Question{
	Name:  dnsmsg.MustParseName("example.com."),
	Type:  dnsmsg.TypeA,
	Class: dnsmsg.ClassINET,
}

// exampleAnswer builds the response an upstream would have returned: one A
// record with the given TTL.
func exampleAnswer(ttl uint32) *dnsmsg.Message {
	return &dnsmsg.Message{
		Header:    dnsmsg.Header{Response: true, RecursionAvailable: true},
		Questions: []dnsmsg.Question{exampleQuestion},
		Answers: []dnsmsg.RR{{
			Name:  exampleQuestion.Name,
			Type:  dnsmsg.TypeA,
			Class: dnsmsg.ClassINET,
			TTL:   ttl,
			Data:  &dnsmsg.A{Addr: netip.MustParseAddr("192.0.2.1")},
		}},
	}
}

func main() {
	clk := clock.NewFake(exampleEpoch)
	c, err := cache.New(cache.Options{
		Clock:      clk,
		ServeStale: time.Hour,
	})
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	// The exchange this response came from used EDNS, which is what gives the
	// stale answer somewhere to carry an Extended DNS Error. A response cached
	// from a plain DNS exchange comes back with the same short TTL and no OPT
	// record — a responder may not add one to a reply whose query had none — and
	// res.Status tells that caller exactly what it told this one.
	answer := exampleAnswer(60)
	answer.SetEDNSDefaults(false)
	c.Put(answer)

	// Ninety seconds on, the entry has expired but is still inside the
	// serve-stale window.
	clk.Advance(90 * time.Second)

	m, res := c.Get(exampleQuestion, false)
	fmt.Println("status:", res.Status)
	fmt.Println("ttl:", m.Answers[0].TTL)

	opt, _ := m.EDNS()
	for _, o := range opt.Options {
		if ede, ok := o.(*dnsmsg.EDNSExtendedError); ok {
			fmt.Println("ede:", ede.InfoCode)
		}
	}

	// Past the window there is nothing left to serve, and the entry is dropped.
	clk.Advance(2 * time.Hour)
	_, res = c.Get(exampleQuestion, false)
	fmt.Println("after the window:", res.Status)

}
Output:
status: stale
ttl: 30
ede: Stale Answer
after the window: expired

func New

func New(opts Options) (*Cache, error)

New returns a cache configured by opts, or the error from Options.Validate, which may report several problems at once.

Every field of opts is optional: New(Options{}) is a working cache with the documented defaults, no metrics, no events and no logging.

func (*Cache) Bytes

func (c *Cache) Bytes() int64

Bytes returns the approximate memory the stored messages occupy.

Approximate is the operative word: the figure comes from this package's own estimate of each message rather than from the allocator, and it counts the stored messages, not the maps and nodes around them. It is the number Options.MaxBytes is compared against, which is what makes it the useful one to report.

It is not the resident cost, and the gap runs both ways with the shape of the traffic. Measured over two hundred thousand entries apiece: a one-A-record response is estimated at 254 bytes against about 380 of real heap once the node, the map slot and the message's own allocations are counted; an NXDOMAIN with an SOA, 303 against 380; a twenty-four record response, 2301 against 1356, the estimate now overshooting because its fixed per-record overhead outruns what a record really costs. Ordinary traffic is mostly the first case, so an operator reading this number as a memory figure should read it as about two thirds of one.

func (*Cache) Close

func (c *Cache) Close() error

Close stops the sweeper, abandons any outstanding refresh, and returns once every goroutine the cache started has exited.

It is idempotent and safe to call concurrently: every caller waits for the same shutdown and none returns early. Returning before the goroutine had actually gone would make a leak test meaningless and would let a sweep run against a cache whose owner believes it is finished.

Close does not discard entries. A cache being replaced during a reload may still be read by whoever holds a reference to it; what Close ends is the background work, not the data. Use Cache.Flush for the data.

The error is always nil today. It is in the signature because Close is what callers defer, and widening a func() to a func() error afterwards would break every one of them.

func (*Cache) Flush

func (c *Cache) Flush()

Flush drops every entry.

It is what an operator reaches for when the cache is known to be wrong — upstream data changed, a policy was edited, a test needs a clean slate — and it is deliberately blunt. Each shard is emptied under its own lock, so a Flush concurrent with traffic is not atomic across the cache: a lookup may still hit a shard the flush has not reached. Making it atomic would mean holding every lock at once, which is a stall on the query path in exchange for a guarantee nobody needs.

No event is published per entry. A flush is one deliberate act, and ten thousand eviction events describing it would drown every subscriber for information they already have.

func (*Cache) FlushSubtree

func (c *Cache) FlushSubtree(n dnsmsg.Name) int

FlushSubtree drops the entry for n and every entry below it, and returns how many it dropped.

A subtree rather than a name, because that is the unit a change arrives in: a zone is re-signed, a delegation moves, a local override is added for a domain and everything under it must stop being answered from what the upstream said. The comparison is on canonical names, so case is irrelevant, and the root drops everything.

It walks every shard, so it is O(entries) and is not something to call per query. That is the honest cost of not maintaining a second index by name suffix, which would have to be updated under lock on every insertion in order to serve an operation that happens when configuration changes.

func (*Cache) Get

func (c *Cache) Get(q dnsmsg.Question, dnssecOK bool) (*dnsmsg.Message, Result)

Get looks up the response to a question and returns a copy of it, or nil.

The returned message is always a fresh copy whose TTLs have been counted down by how long the entry has been held, per RFC 2181 section 8. The caller owns it completely and may set an ID, attach EDNS options or truncate it; the cache keeps its own.

dnssecOK is the DO bit of the query being answered and is part of the key: a response to a DO query carries signatures that a response without it does not, and serving one for the other is a validation failure rather than an inefficiency.

An entry found past its TTL is deleted here, under the lock that found it. That matters more than it looks: a name asked for exactly once would otherwise hold its slot until eviction pressure happened to reach it, which on a cache that is not full is never.

A non-nil message is not on its own permission to send one. Read Result.Status: StatusHit is an answer, while StatusStale is an answer of last resort that RFC 8767 section 5 allows only after an upstream attempt has failed. The cache cannot make that test — it has no upstream — so it returns the message and the label, and the caller that just tried the network decides. A hit on a name the cache holds an NXDOMAIN for is answered from that denial whatever type was asked, per RFC 2308 section 5, with the question section restated as the question that was asked.

func (*Cache) Len

func (c *Cache) Len() int

Len returns how many entries the cache holds.

It is a single atomic load rather than a walk of the shards, so it is cheap enough to call per operation. It is also a point-in-time answer about a cache being written concurrently, which is the only kind of answer available without stopping the world.

func (*Cache) Put

func (c *Cache) Put(m *dnsmsg.Message) bool

Put stores a response and reports whether it was stored.

It refuses everything Cacheable refuses, counting the Reason into Cache.Stats so that a cache which stores nothing can be diagnosed from outside. A refusal is not an error: "this response must not be cached" is a normal outcome of the protocol rather than a fault, and the caller already has the answer it needs.

The message is copied before it is stored, unconditionally. This is the single most likely correctness bug in this package, which is why the copy is not an optimisation anybody may skip: the caller keeps its own message and is entitled to reuse it — dnsmsg.Message.Reset and a pooled message are the recommended way to serve ten thousand queries a second — so an entry holding the caller's message would have its records rewritten, or zeroed, by whatever the caller did next, and the corruption would surface as a wrong answer to a different question minutes later.

func (*Cache) PutFor

func (c *Cache) PutFor(q dnsmsg.Question, dnssecOK bool, m *dnsmsg.Message) bool

PutFor stores a response to the question the caller says it asked, and reports whether it was stored.

This is the insert path a resolver wants. The key is built entirely from q and dnssecOK — the question that went out and the DO bit that went with it — and the response supplies content and nothing else. A response whose own question section disagrees is refused with ReasonQuestionMismatch rather than filed under whichever key it names, because an answer to one question stored as the answer to another is what cache poisoning looks like from in here. Only the caller knows what was asked, so only the caller can close that door.

The message is copied before it is stored, unconditionally. This is the single most likely correctness bug in this package, which is why the copy is not an optimisation anybody may skip: the caller keeps its own message and is entitled to reuse it — dnsmsg.Message.Reset and a pooled message are the recommended way to serve ten thousand queries a second — so an entry holding the caller's message would have its records rewritten, or zeroed, by whatever the caller did next, and the corruption would surface as a wrong answer to a different question minutes later.

func (*Cache) Reclaimed

func (c *Cache) Reclaimed() uint64

Reclaimed reports how many entries the sweeper has removed over the life of the cache.

It is the cheap answer to "is the sweeper doing anything", which is otherwise visible only through a metrics recorder that a deployment may not have configured. It is not part of Stats because it describes the maintenance goroutine rather than the cache's response to traffic, and folding the two together would make the hit rate depend on how often somebody swept.

func (*Cache) Remove

func (c *Cache) Remove(k Key) bool

Remove deletes the entry for k and reports whether there was one.

It is the precise instrument that Cache.Flush is not: a name whose records have just changed can be dropped without discarding everything else the cache has learned.

It removes whatever k would have been answered from, which for a name the cache holds an NXDOMAIN for is that denial — filed under the name rather than under any one type, and so not otherwise reachable through this call. A name that has just started to exist is exactly the case Remove is reached for.

func (*Cache) SetRefresher

func (c *Cache) SetRefresher(fn func(context.Context, Key))

SetRefresher registers a callback invoked when a hit lands on an entry whose remaining lifetime has fallen below Options.PrefetchThreshold, so that a popular record can be renewed before anybody misses on it.

The cache does not resolve, does not know what a resolver is, and will not learn: it reports that a key is worth refreshing, and the callback decides what that means. Milestone 6 wires this to the resolver, which re-queries the key and calls Cache.Put with what comes back.

The contract a refresher may rely on:

  • fn is never called on the lookup path. It runs on its own goroutine, so a refresher that takes two seconds adds nothing to the hit that triggered it.
  • fn is never called twice concurrently for one key, however many goroutines hit that key while the first call is outstanding.
  • At most [maxOutstandingRefreshes] calls are outstanding at once. Past that, refreshes are declined rather than queued: a refresh that has waited in a queue is worth less than the entry it was for, and an unbounded queue of them is only a slower way to run out of memory.
  • fn is handed a context that Cache.Close cancels.

Passing nil disables prefetching, which is also the default. With no refresher registered the whole path is one atomic load per hit, which is what makes it free for the deployments that do not want it. Registering one on a closed cache does nothing.

func (*Cache) StartSweeper

func (c *Cache) StartSweeper(ctx context.Context, interval time.Duration) error

StartSweeper runs a sweep every interval until ctx is done or Cache.Close is called.

Expiring entries as they are looked up is not sufficient, which is the whole reason this exists. An entry is only examined when its own name is queried, so a name asked for once and never again holds its slot until eviction pressure happens to reach it — and on a cache that is not full, that is never. A cache fed by a port scan, a spam run or any other stream of one-shot names therefore fills with entries that are already dead, and then evicts live ones to make room for them. The sweeper is what makes "expired" and "gone" the same thing.

It is one Start with a matching Cache.Close rather than a Start/Stop pair. A cache owns exactly one goroutine and owns it for its whole life, so a separate Stop would only ever be called where Close already is; and a Stop that could be followed by another Start would need the full restartable lifecycle — stopped, running, closed — for a component nobody has a reason to revive. One Close, deferred next to construction, is the shape a caller gets right.

The interval is measured on the cache's clock.Clock, so a test drives the sweep with a clock.Fake instead of waiting. The ticker is registered before this returns, so a test may advance the clock immediately without racing the goroutine into existence.

StartSweeper returns ErrSweepInterval for a non-positive interval, ErrSweeperRunning if a sweeper is already running, and ErrClosed if the cache has been closed.

Example

ExampleCache_StartSweeper shows why the background sweep exists. Expiry on lookup only reaches entries somebody asks for again; a name queried once and never again would hold its slot until eviction pressure happened to reach it. The sweeper runs on the cache's clock, so this example — like the tests — drives it deterministically instead of waiting.

package main

import (
	"context"
	"fmt"
	"log"
	"net/netip"
	"runtime"
	"time"

	"github.com/daboss2003/dns/cache"
	"github.com/daboss2003/dns/clock"
	"github.com/daboss2003/dns/dnsmsg"
)

// exampleEpoch is the instant the examples start from. A recognisable, non-zero
// wall time keeps the expected output readable and avoids the year-1 underflow
// [clock.NewFake] warns about.
var exampleEpoch = time.Date(2024, time.March, 1, 12, 0, 0, 0, time.UTC)

// exampleQuestion is the question every example asks.
var exampleQuestion = dnsmsg.Question{
	Name:  dnsmsg.MustParseName("example.com."),
	Type:  dnsmsg.TypeA,
	Class: dnsmsg.ClassINET,
}

// exampleAnswer builds the response an upstream would have returned: one A
// record with the given TTL.
func exampleAnswer(ttl uint32) *dnsmsg.Message {
	return &dnsmsg.Message{
		Header:    dnsmsg.Header{Response: true, RecursionAvailable: true},
		Questions: []dnsmsg.Question{exampleQuestion},
		Answers: []dnsmsg.RR{{
			Name:  exampleQuestion.Name,
			Type:  dnsmsg.TypeA,
			Class: dnsmsg.ClassINET,
			TTL:   ttl,
			Data:  &dnsmsg.A{Addr: netip.MustParseAddr("192.0.2.1")},
		}},
	}
}

func main() {
	clk := clock.NewFake(exampleEpoch)
	c, err := cache.New(cache.Options{Clock: clk})
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	c.Put(exampleAnswer(60))
	fmt.Println("stored:", c.Len())

	if err := c.StartSweeper(context.Background(), time.Minute); err != nil {
		log.Fatal(err)
	}

	// Two minutes later the entry has expired, and the sweeper's tick removes it
	// although nobody ever asked for it again. The sweep happens on its own
	// goroutine, so this yields until it has run rather than assuming it has;
	// nothing sleeps, and nothing depends on real time passing.
	clk.Advance(2 * time.Minute)
	for c.Reclaimed() == 0 {
		runtime.Gosched()
	}
	fmt.Println("after the sweep:", c.Len())

}
Output:
stored: 1
after the sweep: 0

func (*Cache) Stats

func (c *Cache) Stats() Stats

Stats returns a snapshot of the counters.

They are read shard by shard, so the snapshot is not a single instant on a cache under load and the totals may be a few operations out of date relative to one another. Taking every lock at once to fix that would put an administrative call on the critical path of every query, which is a poor trade for a number that ends up on a dashboard.

func (*Cache) Sweep

func (c *Cache) Sweep() int

Sweep removes every entry whose lifetime has run out and returns how many it reclaimed.

It is what the background sweeper calls on each tick, and is exported so that a caller who would rather drive maintenance from its own scheduler — or a test that needs the sweep to have finished before it asserts — can do so without starting a goroutine.

Sweep takes one shard lock at a time and never holds two. A cache of a million entries is therefore swept as a sequence of short pauses on individual shards rather than one long pause across all of them, so a lookup for an unrelated name waits for at most one shard and usually for nothing at all. Each shard's share of the work is bounded in turn; see [maxScanPerShard].

type Key

type Key struct {
	Name     dnsmsg.Name
	Type     dnsmsg.Type
	Class    dnsmsg.Class
	DNSSECOK bool
}

Key identifies a cached response.

It is a comparable struct so it can be a map key directly. Name is always canonical (lower-cased), which is what makes lookups case-insensitive without a case-insensitive comparison at every level.

DNSSECOK is part of the key because a response to a query with the DO bit set carries RRSIG records that a response to the same question without it does not. Serving one for the other would either strip signatures from a validating client or send signatures to a client that cannot use them, and the first of those is a security failure rather than an inefficiency.

func KeyFor

func KeyFor(q dnsmsg.Question, dnssecOK bool) Key

KeyFor builds the cache key for a question.

func (Key) Question

func (k Key) Question() dnsmsg.Question

Question returns the key as a question, which is how every event and log line in the engine describes a name: the key's DNSSEC OK bit has nowhere to go in a dnsmsg.Question, and a subscriber reading one wants the form the client asked in.

The key of a cached name error carries [denialType], which is not a question anybody asked and which a dnsmsg.Question has no way to say otherwise. A subscriber seeing it is looking at the eviction of a denial that covered every type at that name; Key.String spells that out, and is the better thing to log.

func (Key) String

func (k Key) String() string

String renders the key in the dig-like "name class type" order, with a +do suffix when the DNSSEC OK bit is set. It exists for log lines and cache dumps.

A key filed under [denialType] prints its type as "DENIAL" rather than as the private-use number, because an operator reading a cache dump is owed the reason the entry is there and "TYPE65280" is not it.

type Options

type Options struct {
	// Clock is the time source for expiry and for entry timestamps. Nil means
	// [clock.System]. Tests pass a clock.Fake so that expiry can be exercised
	// without sleeping.
	Clock clock.Clock `json:"-"`

	// MaxEntries is how many entries the cache holds before it evicts. Zero
	// selects [DefaultMaxEntries]. It is a ceiling on the whole cache rather
	// than a target: [Cache.Len] never exceeds it. Dividing it between the
	// shards decides which entries are evicted, not how many survive; see
	// [Cache].
	MaxEntries int `json:"max_entries"`

	// MaxBytes bounds the approximate memory the stored messages occupy, as
	// measured by this package's own estimate rather than by the allocator.
	// Zero means unbounded by size, leaving MaxEntries to decide alone.
	//
	// A byte bound is worth setting even when an entry bound is already in
	// place: entries vary in size by two orders of magnitude between an A
	// record and a signed DNSKEY set, so a count of them says very little about
	// how much memory the cache is actually using.
	//
	// The estimate is not the heap cost, and how far it is from it depends on
	// the shape of the responses. Measured on this package's own fixtures, over
	// two hundred thousand entries each: a one-A-record response estimates at
	// 254 bytes and costs about 380 once the node, the map slot and the
	// message's own allocations are counted, so 1.5x; an NXDOMAIN carrying an
	// SOA, 303 against 380, so 1.3x; a twenty-four record response, 2301
	// against 1356, so 0.6x, because the fixed per-record overhead in the
	// estimate outruns the real cost as messages grow. Ordinary traffic is
	// mostly the first shape, so an operator who writes the number of bytes
	// they are willing to give up should expect to give up about half again.
	//
	// A response larger than one shard's share of this budget is refused with
	// [ReasonTooLarge] rather than stored and immediately evicted. The share is
	// MaxBytes divided by the shard count, so a modest budget on a machine with
	// many cores can refuse responses that look comfortably small.
	MaxBytes int64 `json:"max_bytes"`

	// Shards is how many independently locked partitions the entries are split
	// across. Zero derives a count from GOMAXPROCS. A value that is not a power
	// of two is rounded up to one, because the shard index is masked rather
	// than reduced modulo; values above [MaxShards] are rejected. A cache whose
	// MaxEntries is smaller than the count it asked for gets fewer, since a
	// shard permitted no entries at all could only refuse.
	Shards int `json:"shards"`

	// MinTTL is a floor applied to the lifetime computed from a response. It is
	// the one setting here that overrides what an authority said, so it should
	// be small: raising it holds records past the point their owner intended,
	// which is exactly what breaks a low-TTL failover. Zero means no floor.
	//
	// It cannot lift an entry past the one-week cap described under MaxTTL,
	// which is applied after it.
	MinTTL time.Duration `json:"min_ttl"`

	// MaxTTL is a ceiling on that lifetime. Zero leaves the one-week cap this
	// package applies to every entry regardless (RFC 2181 section 8). That cap
	// is the last word: it is applied after MaxTTL and after MinTTL, so no
	// combination of the two can hold an entry for longer.
	MaxTTL time.Duration `json:"max_ttl"`

	// NegativeTTL is a ceiling for negative answers specifically, applied
	// instead of MaxTTL when it is the lower of the two. Negative answers
	// deserve their own knob because RFC 2308 lifetimes are frequently longer
	// than anyone wants: an SOA MINIMUM of a day means a name that has just
	// been created stays non-existent here for a day. Zero bounds negative
	// answers by MaxTTL like everything else.
	NegativeTTL time.Duration `json:"negative_ttl"`

	// ServeStale is how long past expiry an entry may still answer under
	// RFC 8767. Zero disables serve-stale, which is the default because serving
	// stale data is a correctness trade and must be chosen rather than
	// inherited. Whatever the window, a stale answer leaves with a TTL of
	// [StaleTTL], and with an EDNS Extended DNS Error saying what it is whenever
	// the stored response carried an OPT record to put one in — a response that
	// arrived without EDNS is handed back untouched, because RFC 6891 section
	// 6.1.1 forbids answering a query that carried no OPT record with one.
	//
	// Setting this does not authorise serving stale data whenever it is
	// available. RFC 8767 section 5 permits it only when fresh data cannot be
	// obtained, and the cache cannot tell whether it can: it holds no sockets and
	// speaks to no upstream. So it offers, and the caller — which does know how
	// its own query went — decides. See [Cache.Get] and [StatusStale] for the
	// shape of that decision. While an entry is inside this window, hits on it
	// are offered to any refresher registered with [Cache.SetRefresher], so the
	// window closes on its own once the upstream recovers.
	ServeStale time.Duration `json:"serve_stale"`

	// PrefetchThreshold is the fraction of an entry's lifetime that must remain
	// before a hit offers the key to a refresher registered with
	// [Cache.SetRefresher]. Zero selects [DefaultPrefetchThreshold], and the
	// setting does nothing at all until a refresher exists.
	PrefetchThreshold float64 `json:"prefetch_threshold"`

	// Metrics receives a [metrics.CacheRecord] per operation. Nil means
	// [metrics.Nop], and the cache then skips assembling the record rather than
	// handing it to a recorder that discards it.
	Metrics metrics.Recorder `json:"-"`

	// Events receives cache hit, miss and eviction events. Nil publishes
	// nothing; so does a bus with no subscriber for the kind, which is checked
	// before an event is assembled.
	Events *events.Bus `json:"-"`

	// Logger records configuration and administrative actions — never
	// individual lookups. Nil means [logging.Discard].
	Logger *slog.Logger `json:"-"`
}

Options configures a Cache.

The settings an operator writes in a configuration document carry snake_case JSON tags. The four collaborators — clock, recorder, bus and logger — are marked json:"-" because they are supplied by the program that builds the cache rather than by the document: a file can say how large the cache is, but it cannot hand over a metrics recorder. Every field is optional, and a zero Options is a working cache.

func (Options) Validate

func (o Options) Validate() error

Validate reports every problem in o at once, joined with errors.Join.

Every problem, not the first: an operator who fixes a configuration one error per restart spends an afternoon on what should have been a single edit, and a cache misconfigured in three ways is no rarer than one misconfigured in one.

New calls Validate, so a caller who does not validate separately is not skipping it.

type Reason

type Reason string

Reason explains why a response was not cached. It is carried into metrics and logs so that a low hit rate can be diagnosed from the outside rather than guessed at.

const (
	// ReasonQuestionMismatch means the response does not answer the question
	// passed to [Cache.PutFor]. Storing it anyway would let whoever wrote the
	// response choose the key it lands under, which is the shape of a cache
	// poisoning rather than an inefficiency, so the answer is dropped and the
	// caller told nothing was stored.
	ReasonQuestionMismatch Reason = "response does not answer the question asked"

	// ReasonTooLarge means the response is bigger than one shard's share of
	// [Options.MaxBytes] and so could never survive its own insertion. A counter
	// moving here says the byte budget is too small for the traffic — commonly
	// because it was divided by a shard count derived from GOMAXPROCS — and that
	// no amount of traffic will make the cache hold anything.
	ReasonTooLarge Reason = "larger than a shard's share of the byte budget"
)

Reasons an insertion is declined for something other than the response itself.

They are declared here rather than beside the Cacheable reasons because neither can be settled by reading a message. One is a disagreement between the response and the question the caller says it asked; the other is a disagreement between the response and the size of the cache it is being offered to. Cacheable is given only the message, so it can decide neither.

const (
	ReasonCacheable      Reason = ""
	ReasonNoQuestion     Reason = "no question section"
	ReasonNotResponse    Reason = "not a response"
	ReasonTruncated      Reason = "TC bit set"
	ReasonMetaType       Reason = "meta query type"
	ReasonTransientRCode Reason = "transient failure RCODE"
	ReasonZeroTTL        Reason = "zero TTL"
	ReasonNoTTL          Reason = "no records carrying a TTL"
	ReasonNoSOA          Reason = "negative answer without an SOA"
	ReasonScopedECS      Reason = "response scoped to a client subnet"
	ReasonNotQuery       Reason = "opcode is not QUERY"
)

Reasons a response is not cacheable.

func Cacheable

func Cacheable(m *dnsmsg.Message) (bool, Reason)

Cacheable reports whether a response may be stored, and why not when it may not.

The exclusions are deliberate and each closes a real failure:

  • A truncated response is a fragment. Caching it would serve the fragment to everyone who asks, instead of the full answer they would get over TCP.
  • SERVFAIL and REFUSED are transient and often specific to one upstream. Caching them turns a momentary upstream problem into a sustained outage of our own making. NXDOMAIN and NODATA are different: those are answers, and RFC 2308 says to cache them.
  • ANY, AXFR and IXFR are meta queries whose responses are not a fact about a name that can be reused.
  • A zero TTL means "use once, do not store" (RFC 2181 section 8). Failover and load-balancing schemes depend on that being honoured.
  • A response carrying an ECS option with a non-zero scope is an answer for one client's network, not for everyone. Storing it under a key that does not include the subnet would serve one user's geo-located answer to another. Until the cache keys on subnet, such responses are not stored.

type Result

type Result struct {
	// Status is the outcome. The zero value is [StatusMiss].
	Status Status

	// Stored is when the entry was inserted, zero on a miss.
	Stored time.Time

	// Expires is when the entry's TTL runs out, zero on a miss. On a
	// [StatusStale] or [StatusExpired] result it is in the past, which is how a
	// caller learns how far past.
	Expires time.Time

	// TTL is the value the returned message now carries, in seconds: the
	// remaining lifetime on a hit, [StaleTTL] on a stale answer, zero
	// otherwise.
	TTL uint32
}

Result describes what a lookup found.

The timestamps are the entry's, not the message's. A caller deciding whether to refresh in the background wants to know how old the data is and when it stops being valid, and neither is recoverable from the returned message once its TTLs have been counted down.

type Stats

type Stats struct {
	// Hits is lookups answered from a live entry.
	Hits uint64 `json:"hits"`

	// Misses is lookups that found nothing at all.
	Misses uint64 `json:"misses"`

	// StaleHits is lookups answered from an expired entry under RFC 8767. A
	// rising stale rate means the upstream is failing while clients still see
	// answers, which is the one failure mode that is invisible from the client
	// side.
	StaleHits uint64 `json:"stale_hits"`

	// Expired is lookups that found an entry past its TTL and dropped it. These
	// are not counted as misses: the two say different things about why the hit
	// rate is what it is.
	Expired uint64 `json:"expired"`

	// Insertions is responses stored, including those that replaced an entry
	// for the same key.
	Insertions uint64 `json:"insertions"`

	// Evictions is entries displaced to stay within the entry or byte budget.
	// Explicit removals, flushes and expiries are deliberately excluded:
	// eviction is a memory-pressure signal, and an operator pressing "flush"
	// must not look like one.
	Evictions uint64 `json:"evictions"`

	// Entries is how many entries the cache holds now.
	Entries int `json:"entries"`

	// Bytes is the approximate memory the stored messages occupy now.
	Bytes int64 `json:"bytes"`

	// Refusals counts responses [Cache.Put] declined to store, by [Reason]. It
	// is the first thing to look at when the hit rate is low and the insertion
	// count is lower: a resolver that caches nothing usually has one reason for
	// it, and this names it. Reasons that have never fired are omitted.
	Refusals map[Reason]uint64 `json:"refusals,omitempty"`
}

Stats is a snapshot of a cache's counters.

It is a plain struct with JSON tags because the REST API returns it and dashboards scrape it. Every counter is monotonic for the life of the cache except Entries and Bytes, which are gauges describing it right now.

func (Stats) HitRate

func (s Stats) HitRate() float64

HitRate reports the fraction of lookups answered from cache, between 0 and 1, and 0 for a cache nobody has asked anything.

A stale answer counts as a hit: the client got an answer without waiting for an upstream, which is what the ratio measures. Stats.StaleHits is there for anyone who needs to separate the degraded case out again.

type Status

type Status uint8

Status is the outcome of a Cache.Get.

It is returned alongside the message rather than left to a nil check because "nothing usable" has three distinguishable causes, and a resolver deciding whether to go upstream, to refresh in the background, or simply to count a miss needs to tell them apart.

const (
	// StatusMiss means nothing was stored for the key.
	StatusMiss Status = iota

	// StatusHit means a live entry answered, and the returned message carries
	// its remaining TTL.
	StatusHit

	// StatusStale means the entry had expired and is offered under RFC 8767,
	// with a short TTL and, where the stored response carried an OPT record to
	// put it in, an EDNS Extended DNS Error attached.
	//
	// It means "usable only if your own upstream attempt failed". RFC 8767
	// section 5 conditions stale data on being unable to obtain fresh data, and
	// the cache cannot know whether the caller can — it has no upstream of its
	// own to try. So the message is complete and ready to send, and whether it
	// should be sent is the caller's call: query upstream, and fall back to this
	// only when that comes back empty. A caller that sends every stale message it
	// is handed is serving expired data by choice, not by protocol.
	StatusStale

	// StatusExpired means an entry was found, was past its TTL, was not
	// eligible to be served stale, and has been dropped. It is reported apart
	// from a miss because a cache full of dead entries and a cache full of
	// names nobody asks twice look identical otherwise.
	StatusExpired
)

Lookup outcomes. The zero value is StatusMiss, so the zero Result — the one returned beside a nil message — already reads correctly.

func (Status) String

func (s Status) String() string

String returns the lower-case mnemonic for the status, or a "Status(n)" form for a value outside the defined set.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL